MongoDB分页的Java实现和分页需求的思考
前言
传统关系数据库中都提供了基于row number的分页功能,切换MongoDB后,想要实现分页,则需要修改一下思路。
传统分页思路
假设一页大小为10条。则
//page 1
1-10
//page 2
11-20
//page 3
21-30
...
//page n
10*(n-1) +1 - 10*n
MongoDB提供了skip()和limit()方法。
skip: 跳过指定数量的数据. 可以用来跳过当前页之前的数据,即跳过pageSize*(n-1)。
limit: 指定从MongoDB中读取的记录条数,可以当做页面大小pageSize。
所以,分页可以这样做:
//Page 1
db.users.find().limit (10)
//Page 2
db.users.find().skip(10).limit(10)
//Page 3
db.users.find().skip(20).limit(10)
........
问题
看起来,分页已经实现了,但是官方文档并不推荐,说会扫描全部文档,然后再返回结果。
The cursor.skip() method requires the server to scan from the beginning of the input results set before beginning to return results. As the offset increases, cursor.skip() will become slower.
所以,需要一种更快的方式。其实和mysql数量大之后不推荐用limit m,n一样,解决方案是先查出当前页的第一条,然后顺序数pageSize条。MongoDB官方也是这样推荐的。
正确的分页办法
我们假设基于_id的条件进行查询比较。事实上,这个比较的基准字段可以是任何你想要的有序的字段,比如时间戳。
//Page 1
db.users.find().limit(pageSize);
//Find the id of the last document in this page
last_id = ...
//Page 2
users = db.users.find({
'_id' :{ "$gt" :ObjectId("5b16c194666cd10add402c87")}
}).limit(10)
//Update the last id with the id of the last document in this page
last_id = ...
显然,第一页和后面的不同。对于构建分页API, 我们可以要求用户必须传递pageSize, lastId。
- pageSize 页面大小
- lastId 上一页的最后一条记录的id,如果不传,则将强制为第一页
降序
_id
降序,第一页是最大的,下一页的id比上一页的最后的id还小。
function printStudents(startValue, nPerPage) {
let endValue = null;
db.students.find( { _id: { $lt: startValue } } )
.sort( { _id: -1 } )
.limit( nPerPage )
.forEach( student => {
print( student.name );
endValue = student._id;
} );
return endValue;
}
升序
_id
升序, 下一页的id比上一页的最后一条记录id还大。
function printStudents(startValue, nPerPage) {
let endValue = null;
db.students.find( { _id: { $gt: startValue } } )
.sort( { _id: 1 } )
.limit( nPerPage )
.forEach( student => {
print( student.name );
endValue = student._id;
} );
return endValue;
}
一共多少条
还有一共多少条和多少页的问题。所以,需要先查一共多少条count.
db.users.find().count();
ObjectId的有序性问题
先看ObjectId生成规则:
比如"_id" : ObjectId("5b1886f8965c44c78540a4fc")
取id的前4个字节。由于id是16进制的string,4个字节就是32位,对应id前8个字符。即5b1886f8
, 转换成10进制为1528334072
. 加上1970,就是当前时间。
事实上,更简单的办法是查看org.mongodb:bson:3.4.3里的ObjectId对象。
public ObjectId(Date date) {
this(dateToTimestampSeconds(date), MACHINE_IDENTIFIER, PROCESS_IDENTIFIER, NEXT_COUNTER.getAndIncrement(), false);
}
//org.bson.types.ObjectId#dateToTimestampSeconds
private static int dateToTimestampSeconds(Date time) {
return (int)(time.getTime() / 1000L);
}
//java.util.Date#getTime
/**
* Returns the number of milliseconds since January 1, 1970, 00:00:00 GMT
* represented by this <tt>Date</tt> object.
*
* @return the number of milliseconds since January 1, 1970, 00:00:00 GMT
* represented by this date.
*/
public long getTime() {
return getTimeImpl();
}
MongoDB的ObjectId应该是随着时间而增加的,即后插入的id会比之前的大。但考量id的生成规则,最小时间排序区分是秒,同一秒内的排序无法保证。当然,如果是同一台机器的同一个进程生成的对象,是有序的。
如果是分布式机器,不同机器时钟同步和偏移的问题。所以,如果你有个字段可以保证是有序的,那么用这个字段来排序是最好的。_id
则是最后的备选方案。
如果我一定要跳页
上面的分页看起来看理想,虽然确实是,但有个刚需不曾指明---我怎么跳页。
我们的分页数据要和排序键关联,所以必须有一个排序基准来截断记录。而跳页,我只知道第几页,条件不足,无法分页了。
现实业务需求确实提出了跳页的需求,虽然几乎不会有人用,人们更关心的是开头和结尾,而结尾可以通过逆排序的方案转成开头。所以,真正分页的需求应当是不存在的。如果你是为了查找某个记录,那么查询条件搜索是最快的方案。如果你不知道查询条件,通过肉眼去一一查看,那么下一页足矣。
说了这么多,就是想扭转传统分页的概念,在互联网发展的今天,大部分数据的体量都是庞大的,跳页的需求将消耗更多的内存和cpu,对应的就是查询慢。
当然,如果数量不大,如果不介意慢一点,那么skip也不是啥问题,关键要看业务场景。
我今天接到的需求就是要跳页,而且数量很小,那么skip吧,不费事,还快。
来看看大厂们怎么做的
Google最常用了,看起来是有跳页选择的啊。再仔细看,只有10页,多的就必须下一页,并没有提供一共多少页,跳到任意页的选择。这不就是我们的find-condition-then-limit方案吗,只是他的一页数量比较多,前端或者后端把这一页给切成了10份。
同样,看Facebook,虽然提供了总count,但也只能下一页。
其他场景,比如Twitter,微博,朋友圈等,根本没有跳页的概念的。
排序和性能
前面关注于分页的实现原理,但忽略了排序。既然分页,肯定是按照某个顺序进行分页的,所以必须要有排序的。
MongoDB的sort和find组合
db.bios.find().sort( { name: 1 } ).limit( 5 )
db.bios.find().limit( 5 ).sort( { name: 1 } )
这两个都是等价的,顺序不影响执行顺序。即,都是先find查询符合条件的结果,然后在结果集中排序。
我们条件查询有时候也会按照某字段排序的,比如按照时间排序。查询一组时间序列的数据,我们想要按照时间先后顺序来显示内容,则必须先按照时间字段排序,然后再按照id升序。
db.users.find({name: "Ryan"}).sort( { birth: 1, _id: 1 } ).limit( 5 )
我们先按照birth升序,然后birth相同的record再按照_id升序,如此可以实现我们的分页功能了。
多字段排序
db.records.sort({ a:1, b:-1})
表示先按照a升序,再按照b降序。即,按照字段a升序,对于a相同的记录,再用b降序,而不是按a排完之后再全部按b排。
示例:
db.user.find();
结果:
{
"_id" : ObjectId("5b1886ac965c44c78540a4fb"),
"name" : "a",
"age" : 1.0,
"id" : "1"
}
{
"_id" : ObjectId("5b1886f8965c44c78540a4fc"),
"name" : "a",
"age" : 2.0,
"id" : "2"
}
{
"_id" : ObjectId("5b1886fa965c44c78540a4fd"),
"name" : "b",
"age" : 1.0,
"id" : "3"
}
{
"_id" : ObjectId("5b1886fd965c44c78540a4fe"),
"name" : "b",
"age" : 2.0,
"id" : "4"
}
{
"_id" : ObjectId("5b1886ff965c44c78540a4ff"),
"name" : "c",
"age" : 10.0,
"id" : "5"
}
按照名称升序,然后按照age降序
db.user.find({}).sort({name: 1, age: -1})
结果:
{
"_id" : ObjectId("5b1886f8965c44c78540a4fc"),
"name" : "a",
"age" : 2.0,
"id" : "2"
}
{
"_id" : ObjectId("5b1886ac965c44c78540a4fb"),
"name" : "a",
"age" : 1.0,
"id" : "1"
}
{
"_id" : ObjectId("5b1886fd965c44c78540a4fe"),
"name" : "b",
"age" : 2.0,
"id" : "4"
}
{
"_id" : ObjectId("5b1886fa965c44c78540a4fd"),
"name" : "b",
"age" : 1.0,
"id" : "3"
}
{
"_id" : ObjectId("5b1886ff965c44c78540a4ff"),
"name" : "c",
"age" : 10.0,
"id" : "5"
}
用索引优化排序
到这里必须考虑下性能。
$sort and Memory Restrictions
The $sort stage has a limit of 100 megabytes of RAM. By default, if the stage exceeds this limit, $sort will produce an error. To allow for the handling of large datasets, set the
allowDiskUse
option to true to enable $sort operations to write to temporary files. See the allowDiskUse option in db.collection.aggregate() method and the aggregate command for details.Changed in version 2.6: The memory limit for $sort changed from 10 percent of RAM to 100 megabytes of RAM.
从2.6开始,sort只排序100M以内的数据,超过将会报错。可以通过设置allowDiskUse
来允许排序大容量数据。
有索引的排序会比没有索引的排序快,所以官方推荐为需要排序的key建立索引。
索引
对于单key排序,建立单独索引
db.records.createIndex( { a: 1 } )
索引可以支持同排序和逆序的sort
索引又分升序(1)和降序(-1),索引定义的排序方向以及逆转方向可以支持sort。对于上述单key索引a,可以支持sort({a:1})
升序和sort({a:-1})
降序。
对于多字段排序
如果想要使用索引。则可以建立复合(compound index)索引为
db.records.createIndex( { a: 1, b:-1 } )
复合索引的字段顺序必须和sort一致
复合多字段索引的顺序要和sort的字段一致才可以走索引。比如索引{a:1, b:1}
, 可以支持sort({a:1, b:1})
和逆序sort({a:-1, b:-1})
, 但是,不支持a,b颠倒。即,不支持sort({b:1, a:1})
.
复合索引支持sort同排序和逆序
索引{a:1, b:-1}
可以支持sort({a:1, b:-1})
, 也可以支持sort({a:-1, b:1})
复合索引可以前缀子集支持sort
对于多字段复合索引,可以拆分成多个前缀子集。比如{a:1, b:1, c:1}
相当于
{ a: 1 }
{ a: 1, b: 1 }
{ a: 1, b: 1, c: 1 }
示例:
Example | Index Prefix |
---|---|
db.data.find().sort( { a: 1 } ) | { a: 1 } |
db.data.find().sort( { a: -1 } ) | { a: 1 } |
db.data.find().sort( { a: 1, b: 1 } ) | { a: 1, b: 1 } |
db.data.find().sort( { a: -1, b: -1 } ) | { a: 1, b: 1 } |
db.data.find().sort( { a: 1, b: 1, c: 1 } ) | { a: 1, b: 1, c: 1 } |
db.data.find( { a: { $gt: 4 } } ).sort( { a: 1, b: 1 } ) | { a: 1, b: 1 } |
复合索引的非前缀子集可以支持sort,前提是前缀子集的元素要在find的查询条件里是equals
这个条件比较绕口,复合索引的非前缀子集,只要find和sort的字段要组成索引前缀,并且find里的条件必须是相等。
示例
Example | Index Prefix |
---|---|
db.data.find( { a: 5 } ).sort( { b: 1, c: 1 } ) | { a: 1 , b: 1, c: 1 } |
db.data.find( { b: 3, a: 4 } ).sort( { c: 1 } ) | { a: 1, b: 1, c: 1 } |
db.data.find( { a: 5, b: { $lt: 3} } ).sort( { b: 1 } ) | { a: 1, b: 1 } |
find和sort的字段加起来满足前缀子集,find条件中可以使用其他字段进行非equals比较。
对于既不是前缀子集,也不是find相等条件的。索引无效。比如,对于索引{a:1, b:1, c:1}
。以下两种方式不走索引。
db.data.find( { a: { $gt: 2 } } ).sort( { c: 1 } )
db.data.find( { c: 5 } ).sort( { c: 1 } )
Java代码分页
由于确实有跳页的需求,目前还没有发现性能问题,仍旧采用skip做分页,当然也兼容条件分页
public PageResult<StatByClientRs> findByDurationPage(FindByDurationPageRq rq) {
final Criteria criteriaDefinition = Criteria.where("duration").is(rq.getDuration());
final Query query = new Query(criteriaDefinition).with(new Sort(Lists.newArrayList(new Order(Direction.ASC, "_id"))));
//分页逻辑
long total = mongoTemplate.count(query, StatByClient.class);
Integer pageSize = rq.getPageSize();
Integer pageNum = rq.getPageNum();
String lastId = rq.getLastId();
final Integer pages = (int) Math.ceil(total / (double) pageSize);
if (pageNum<=0 || pageNum> pages) {
pageNum = 1;
}
if (StringUtils.isNotBlank(lastId)) {
if (pageNum != 1) {
criteriaDefinition.and("_id").gt(new ObjectId(lastId));
}
query.limit(pageSize);
} else {
int skip = pageSize * (pageNum - 1);
query.skip(skip).limit(pageSize);
}
List<StatByClient> statByClientList = mongoTemplate.find(query, StatByClient.class);
PageResult<StatByClientRs> pageResult = new PageResult<>();
pageResult.setTotal(total);
pageResult.setPages(pages);
pageResult.setPageSize(pageSize);
pageResult.setPageNum(pageNum);
pageResult.setList(mapper.mapToListRs(statByClientList));
return pageResult;
}
这个示例中,目标是根据duration查询list,结果集进行分页。当请求体中包含lastId
,那就走下一页方案。如果想要跳页,就不传lastId
,随便你跳吧。
抽取分页代码为公共工具类
考虑分页需求的旺盛,每个集合都这样写感觉比较麻烦,而且容易出错。我们来把这个封装成单独一个PageHelper
import com.google.common.collect.Lists;
import com.shuwei.d2.message.PageResult;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Component;
/**
* MongoDB分页查询工具类.
*
* @author Ryan Miao at 2018-06-07 14:46
**/
@Component
public class MongoPageHelper {
public static final int FIRST_PAGE_NUM = 1;
public static final String ID = "_id";
private final MongoTemplate mongoTemplate;
@Autowired
public MongoPageHelper(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
/**
* 分页查询,直接返回集合类型的结果.
*
* @see MongoPageHelper#pageQuery(org.springframework.data.mongodb.core.query.Query,
* java.lang.Class, java.util.function.Function, java.lang.Integer, java.lang.Integer,
* java.lang.String)
*/
public <T> PageResult<T> pageQuery(Query query, Class<T> entityClass, Integer pageSize,
Integer pageNum) {
return pageQuery(query, entityClass, Function.identity(), pageSize, pageNum, null);
}
/**
* 分页查询,不考虑条件分页,直接使用skip-limit来分页.
*
* @see MongoPageHelper#pageQuery(org.springframework.data.mongodb.core.query.Query,
* java.lang.Class, java.util.function.Function, java.lang.Integer, java.lang.Integer,
* java.lang.String)
*/
public <T, R> PageResult<R> pageQuery(Query query, Class<T> entityClass, Function<T, R> mapper,
Integer pageSize, Integer pageNum) {
return pageQuery(query, entityClass, mapper, pageSize, pageNum, null);
}
/**
* 分页查询.
*
* @param query Mongo Query对象,构造你自己的查询条件.
* @param entityClass Mongo collection定义的entity class,用来确定查询哪个集合.
* @param mapper 映射器,你从db查出来的list的元素类型是entityClass, 如果你想要转换成另一个对象,比如去掉敏感字段等,可以使用mapper来决定如何转换.
* @param pageSize 分页的大小.
* @param pageNum 当前页.
* @param lastId 条件分页参数, 区别于skip-limit,采用find(_id>lastId).limit分页.
* 如果不跳页,像朋友圈,微博这样下拉刷新的分页需求,需要传递上一页的最后一条记录的ObjectId。 如果是null,则返回pageNum那一页.
* @param <T> collection定义的class类型.
* @param <R> 最终返回时,展现给页面时的一条记录的类型。
* @return PageResult,一个封装page信息的对象.
*/
public <T, R> PageResult<R> pageQuery(Query query, Class<T> entityClass, Function<T, R> mapper,
Integer pageSize, Integer pageNum, String lastId) {
//分页逻辑
long total = mongoTemplate.count(query, entityClass);
final Integer pages = (int) Math.ceil(total / (double) pageSize);
if (pageNum <= 0 || pageNum > pages) {
pageNum = FIRST_PAGE_NUM;
}
final Criteria criteria = new Criteria();
if (StringUtils.isNotBlank(lastId)) {
if (pageNum != FIRST_PAGE_NUM) {
criteria.and(ID).gt(new ObjectId(lastId));
}
query.limit(pageSize);
} else {
int skip = pageSize * (pageNum - 1);
query.skip(skip).limit(pageSize);
}
final List<T> entityList = mongoTemplate
.find(query.addCriteria(criteria)
.with(new Sort(Lists.newArrayList(new Order(Direction.ASC, ID)))),
entityClass);
final PageResult<R> pageResult = new PageResult<>();
pageResult.setTotal(total);
pageResult.setPages(pages);
pageResult.setPageSize(pageSize);
pageResult.setPageNum(pageNum);
pageResult.setList(entityList.stream().map(mapper).collect(Collectors.toList()));
return pageResult;
}
}
对了,还有PageResult对象
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import io.swagger.annotations.ApiModelProperty;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 分页结果.
* @author Ryan
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(Include.NON_NULL)
public class PageResult<T> {
@ApiModelProperty("页码,从1开始")
private Integer pageNum;
@ApiModelProperty("页面大小")
private Integer pageSize;
@ApiModelProperty("总数")
private Long total;
@ApiModelProperty("总页数")
private Integer pages;
@ApiModelProperty("数据")
private List<T> list;
}
使用工具类
最初的查询语句,业务逻辑和分页逻辑分开。
public PageResult<StatByClientRs> findByDurationPage(FindByDurationPageRq rq) {
final Query query = new Query(Criteria.where("duration").is(rq.getDuration()));
return mongoPageHelper.pageQuery(query, StatByClient.class, mapper::mapToRs, rq.getPageSize(),
rq.getPageNum(), rq.getLastId());
}
把工具类共享到maven仓库
新建一个maven项目,https://github.com/Ryan-Miao/mongo-page-helper
修改并提取刚才的工具类。
如何使用
必须结合spring-boot-starter-data-mongodb来使用.
在pom里添加repository
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
再引入依赖
<dependency>
<groupId>com.github.Ryan-Miao</groupId>
<artifactId>mongo-page-helper</artifactId>
<version>1.0</version>
</dependency>
配置Configuration
@Configuration
public class MongoConfiguration{
@Autowired
private MongoTemplate mongoTemplate;
@Bean
public MongoPageHelper mongoPageHelper() {
return new MongoPageHelper(mongoTemplate);
}
}
然后就可以使用MongoPageHelper来注入了。
参考
- 官方分页推荐
- 官方sort文档
- 官方使用索引优化sort文档
- 官方复合索引
- 如何正确看待分页的需求
- http://ian.wang/35.htm
- https://cnodejs.org/topic/559a0bf493cb46f578f0a601
MongoDB分页的Java实现和分页需求的思考的更多相关文章
- Mybatis包分页查询java公共类
Mybatis包分页查询java公共类 分页----对于数据量非常大的查询中.是不可缺少的. mybatis底层的分页sql语句因为须要我们自己去手动写.而实现分页显示的时候我们须要依据分页查询条 ...
- cassandra高级操作之分页的java实现(有项目具体需求)
接着上篇博客,我们来谈谈java操作cassandra分页,需要注意的是这个分页与我们平时所做的页面分页是不同的,具体有啥不同,大家耐着性子往下看. 上篇博客讲到了cassandra的分页,相信大家会 ...
- MongoDB整理笔记のjava MongoDB分页优化
最近项目在做网站用户数据新访客统计,数据存储在MongoDB中,统计的数据其实也并不是很大,1000W上下,但是公司只配给我4G内存的电脑,让我程序跑起来气喘吁吁...很是疲惫不堪. 最常见的问题莫过 ...
- Java Web -【分页功能】详解
分页简介 分页功能在网页中是非常常见的一个功能,其作用也就是将数据分割成多个页面来进行显示. 使用场景: 当取到的数据量达到一定的时候,就需要使用分页来进行数据分割. 当我们不使用分页功能的时候,会面 ...
- 基于C#在Mongodb的Skip-Limit和Where-Limit的分页对比 并且含mongodb帮助类的源码
最近在设计的日志服务中需要用到Mongodb这个Nosql数据库(不知道Mongodb的点我),由于是用于纯存日志,而且日志量巨大,百万千万级的,所以需要用到它的分页查询. 不过LZ也是刚刚接触这个数 ...
- Java标签实现分页
Java实现标签分页 最近为了开发一个网站,里面要用分页功能,但是之前很少自己写分页标签,又不想用现成框架.所以自己参考了些资料,写了个分页例子测试了一下. 代码主要分为三个类: PageTag 分页 ...
- 【小技巧】java的List分页
今天,工作上,由于业务的一些特殊性,需要拿到数据后在java代码中进行分页. 写了一个工具类,记录如下: import java.util.ArrayList; import java.util.Li ...
- Mongodb系列- spring-data-mongodb使用MongoTemplate实现分页查询
在用spring-data-mongodb框架开发的过程中,需要实现分页查询,就百度了下,没找到满意的又google了下,找到了思路. 在spring-data-mongodb 官方文档中,建议你使用 ...
- laravel基础课程---15、分页及验证码(lavarel分页效果如何实现)
laravel基础课程---15.分页及验证码(lavarel分页效果如何实现) 一.总结 一句话总结: 数据库的paginate方法:$data=\DB::table("user" ...
随机推荐
- Codeforces Beta Round #14 (Div. 2) B. Young Photographer 水题
B. Young Photographer 题目连接: http://codeforces.com/contest/14/problem/B Description Among other thing ...
- (84)Wangdao.com第十八天_JavaScript 文档对象模型 DOM
文档对象模型 DOM DOM 是 JavaScript 操作网页的接口, 全称为“文档对象模型”(Document Object Model). 作用是将网页转为一个 JavaScript 对象,从而 ...
- OpenVPN选项topology subnet实现子网掩码24的子网地址扩展
首先,在国内的文章中都没有提及这个概念,到时很多人生成的VPN服务端配置分配的IP都采用net30这种形式,这就导致了在任何一台VPN客户端上无法实现路由指向,因为子网掩码30换算出来就两个地址. t ...
- Mac 10.13安装telnet
狗日的Mac 10.13默认不自带telnet!!!苹果你以为你的操作系统真的那么平民吗,别做梦,用你只不过是为了开发!!! 安装: brew install telnet 如果你用上述方法安装不上, ...
- STM32F4 Timer simplified block diagram
Timers TIM1 and TIM8 use 16-bit counters and are the most complex timers of all timers included in t ...
- 提高你的Java代码质量吧:谨慎包装类型的比较
一.分析 基本类型可以比较大小,其所对应的包装类型都实现了Comparable接口此问题. 二.场景 代码如下: public class Client{ public static void m ...
- Java 下载文件
public @ResponseBody void exportExcel(HttpServletRequest request, HttpServletResponse response, Khxx ...
- Unity Shader-渲染队列,ZTest,ZWrite,Early-Z
在渲染阶段,引擎所做的工作是把所有场景中的对象按照一定的策略(顺序)进行渲染.最早的是画家算法,顾名思义,就是像画家画画一样,先画后面的物体,如果前面还有物体,那么就用前面的物体把物体覆盖掉,不过这种 ...
- 百度搜索URL参数你知道多少
http://www.baidu.com/s?wd=关键字 wd(Keyword):查询的关键词: http://www.baidu.com/s?wd=关键字&cl=3 cl(Class):搜 ...
- 闪电侠第四季/全集The Flash迅雷下载
闪电侠第四季>(The Flash Season 4)是DC娱乐和华纳联手CW电视台制作的真人超级英雄系列剧,是美剧<闪电侠>系列的第四季. 该季于2017年10月10日在美国CW电 ...