MongoDB 4.x副本集
1、副本集架构
在前面,我们已经完成了MongoDB在单机上的安装,并进行了基本的功能体验。然而,在生产环境中,不建议使用单机版的MongoDB服务器。原因如下:
- 单机版的MongoDB无法保证可靠性,一旦进程发生故障或是服务器宕机,业务将直接不可用。
- 一旦服务器上的磁盘损坏,数据会直接丢失,而此时并没有任何副本可用。
于是,任何生产环境的数据库都应该拥有一个或多个可用的副本实例,在出现任何异常情况时,能第一时间恢复数据库的读写访问。这通常可以称之为数据库的高可用,而如何实现高可用的架构也一定是现代数据库需要解决的关键问题。
对于MongoDB来说,数据库高可用是通过副本集架构(Replication Set)实现的,一个副本集由一个主节点(Primary)和若干个备节点(Secondary)所组成。一个典型的副本集架构如图所示。

客户端通过数据库主节点写入数据后,由备节点进行复制同步,这样所有备节点都会同时拥有这些业务数据的副本,当主节点发生故障而变得不可用时,备节点能主动发起选举并产生新的主节点进行接管,此时,客户端仍然能继续进行访问,这保证了业务的连续性。
下面的这个过程,更准确地描述了副本集的高可用机制:
- 搭建副本集,各节点会进行初始化选举,决定谁是主、谁是备。
- 各节点都开始工作,主节点负责接收客户端写入的数据,备节点则负责复制这些数据。
- 主节点发生故障,备节点通过心跳检测到了问题。
- 某个备节点率先发起新一轮选举,一举成为新的主节点。
- 客户端感知到主节点的变化,将后续的数据写入指向新的主节点。
可以发现,在上述过程中,并没有用到任何其他的技术。相比一些需要借助第三方HA组件实现高可用的数据库来说,MongoDB自身就提供了高可用的能力。
早期版本的MongoDB使用了一种Master-Slave的架构,该做法在MongoDB 3.4版本之后已经废弃。
为了进一步理解MongoDB副本集架构,我们通常需要了解如下几个关键点:
- 选举机制。
- 实时复制。
- 故障转移。
2、集群选举
2.1、Raft选举算法
MongoDB的副本集选举使用Raft算法来实现,这是一种使用广泛的分布式一致性算法,为了让读者更深入地理解MongoDB副本集中的一些概念,我们先来了解一下这个算法。
2.1.1、范例
Raft选举的设计思路基本来自现实中的场景,以民主选举中的总统大选为例,每个总统通常都有一个任期阶段,这是体制决定的一个周期性时间,比如三到五年。在任期结束后,又会重新进行选举来决定总统的任命。
由于是民主社会,总统的人选可以从普通的民众当中产生,这些人需要先成为总统候选者(Candidate),然后到处发表演讲以获得更多的支持。最终,由选民进行公平的投票,谁的票数多,谁就能当上总统(Leader)。当然,在选举时可能会发生平票这种小概率事件,那么就会进行新一轮的选举,直到总统被选举出来。
在总统上任之后,他还要到处去演讲,告诉所有人自己成为总统这个事实,而这些事情都是为了巩固总统的地位。
在上述案例中,基本上已经说明了Raft协议的一些关键要素。那么接下来,我们看看Raft协议具体是怎么定义的。
2.1.2、协议中的角色
Leader:领导者,Leader会向其他节点发送心跳,同时负责处理客户端的读写操作,包括将数据同步到其他节点。Follower:追随者,响应来自Leader和Candidate的投票请求,如果在一定时间内没有收到Leader的心跳,则会转换为Candidate。Candidate:候选者,Follower主动选举转换成为Candidate,获得大多数投票后会成为Leader。
在选举中有一个重要的概念叫任期(Term),一个任期对应一次选举,在Raft协议中任期被设计为单调递增的数字。每个节点上都会记录一个对应的任期,代表它所处于的任期(阶段)。在选举的过程中,节点之间会通过任期的比较来解决冲突问题,此时任期比较新的节点会被接受。
在一定条件下,上述几种角色会发生相互转换,如图所示。

2.1.3、选举流程
在开始时,所有节点都是Follower,此时大家都没有办法收到Leader的心跳。接下来,A节点出现等待超时,率先发起选举,并成为Candidate。A节点先是给自己投一票,然后接着向其他节点发送投票请求,一旦A节点获得了集群中大多数节点的投票,则会成为Leader,同时开始向其他节点广播心跳,以此来声明自己的Leader角色。这个过程如图所示。


上面的过程仅仅是最简单的情况,实际上的投票选举则可能比这要复杂一些,并且会伴随一些冲突或异常出现。为了保证能达到最终的一致性,Raft协议还加入了以下细节。
- 在同一个任期内,每个节点最多只能给一个Candidate投票(节点内部进行记录),任期内投票采用先到先得的原则。
- 节点在收到Candidate的投票请求时,只有当对方的任期、操作日志时间至少与自己的一样新时,才会给它投票。
- Candidate发起投票后,如果一直没有得到大多数票,则会一直保持这个状态直到超时,此后将继续发起新一轮任期的选举(Term自增);如果在投票期间检测到了Leader的心跳(其他Candidate率先完成选主),则会判断当前Leader的任期是否至少跟自己一样新,如果是则降级为Follower,并承认对方的Leader角色,否则不予理会。
- 无论是Candidate还是Leader节点,一旦发现了其他节点有更新的任期(Term值),都会自动降级为Follower。
2.1.4、冲突
选举过程中解决冲突的关键在于对Term值的判断。而在分布式环境中,冲突的情况是必然会产生的,下面列举了一些可能出现的场景。
- 场景A.多个候选者竞争,大多数获胜,如图所示。

- 场景B.多个候选者竞争,平票,如图所示。

如果集群节点个数是偶数,那么可能会产生平票,也就需要进行下一轮选举。通过为每个节点增加一个随机的选举延期时间,可以大大降低出现平票的概率。
- 场景C.网络分区,造成Term值不一致,如图所示。

如图所示,当网络出现分区时,B节点变得不可达,仅剩下A、C节点进行选举。
此时,由于B节点会一直无法选举成功,且会一直超时重试,最终则造成该节点的Term值激增。
一旦网络情况恢复,则B节点将在很长时间内不会给其他节点投票(由于自身Term值过高),而同时自身也无法获得其他节点的投票(由于自身的日志太旧),这对于集群选举来说都是非常不利的。因此Raft协议中提出了一种预投票的手段,即实现通过预先投票的方式试探自己能否选举成功,只有预投票通过了才进行真正的投票;而预投票不会造成Term值自增,这样就解决了Term值差距太大的问题。
2.2、MongoDB实现的扩展
如前面所述,MongoDB是基于Raft协议的,在副本集选举、复制的机制中都能看到与标准Raft协议的影子。但在其具体的实现中,MongoDB仍然添加了一些自己的扩展,这包括:
- 支持chainingAllowed链式复制,即备节点不只是从主节点上同步数据,还可以选择一个离自己最近(心跳延时最小)的节点来复制数据。
- 增加了预投票阶段,即preVote,这主要是用来避免网络分区时产生Term值激增的问题,可以参照前面内容中提到的“4.冲突—场景C”。
- 支持投票优先级,如果备节点发现自己的优先级比主节点高,则会主动发起投票并尝试成为新的主节点。
2.3、MongoDB选举介绍
有了前面的理论基础,我们就可以轻松地理解MongoDB副本集的一些设计了,比如“大多数原则”的由来,这是因为Raft协议的选举机制中Leader必须通过大多数节点投票才能产生。我们假设副本集内的投票成员数量为N,则大多数为N/2+1。这个计算见下表。

当副本集内存活的成员数量不足大多数时,整个副本集将无法选举出主节点,此时无法提供写服务,这些节点都将处于只读状态。此外,如果希望避免平票结果的产生,最好使用奇数个节点成员,比如3个或5个。当然,在MongoDB副本集的实现中,对于平票问题已经提供了解决方案:
- 为选举定时器增加少量的随机时间偏差,这样避免各个节点在同一时刻发起选举,提高成功率。
- 使用仲裁者角色,该角色不做数据复制,也不承担读写业务,仅仅用来投票。
此外,在一个MongoDB副本集中,最多只能有50个成员,而参与投票的成员最多只能有7个。这是因为一旦过多的成员参与数据复制、投票过程,将会带来更多可靠性方面的问题。
成员角色:MongoDB为副本集成员提供了多种角色,具体如下。
Primary:主节点,其接收所有的写请求,然后把修改同步到所有备节点。一个副本集只能有一个主节点,当主节点“挂掉”后,其他节点会重新选举出来一个主节点。Secondary:备节点,与主节点保持同样的数据集。当主节点“挂掉”时,参与竞选主节点。Arbiter:仲裁者节点,该节点只参与投票,不能被选为主节点,并且不从主节点中同步数据。当节点宕机导致复制集无法选出主节点时,可以给复制集添加一个仲裁者节点,这样即使有节点宕机,仍能选出主节点。仲裁者节点本身不存储数据,是非常轻量级的服务。当复制集成员为偶数时,最好加入一个仲裁者节点,以提升复制集的可用性。Priority0:优先级为0的节点,该节点永远不会被选举为主节点,也不会主动发起选举。通常,在跨机房方式下部署副本集可以使用该特性。假设使用了机房A和机房B,由于主要业务与机房A更近,则可以将机房B的复制集成员Priority设置为0,这样主节点就一定会是A机房的成员。Hidden:隐藏节点,具备Priority0的特性,即不能被选为主节点(Priority为0),同时该节点对客户端不可见。由于隐藏节点不会接受业务访问,因此可通过隐藏节点做一些数据备份、离线计算的任务,这并不会影响整个副本集。Delayed:延迟节点,必须同时具备隐藏节点和Priority0的特性,并且其数据落后于主节点一段时间,该时间是可配置的。由于延迟节点的数据比主节点落后一段时间,当错误或者无效的数据写入主节点时,可通过延迟节点的数据来恢复到之前的时间点。Vote0:无投票权的节点,必须同时设定为Priority0节点。由于一个副本集中最多只有7个投票成员,因此多出来的成员则必须将其vote属性值设置为0,即这些成员将无法参与投票。
一般来说,成员能否成为主节点,主要受某些因素的影响,这包括节点之间的心跳、节点优先级,以及OpLog时间戳。而触发一次选举,通常会来自下面的场景:
- 初始化一个副本集时。
- 备节点在一段时间内发现不了主节点(默认10s超时),由备节点发起选举。
- 主节点放弃自己的角色,比如执行rs.stepDown命令。
2.4、副本集模式
常见的副本集架构由3个成员节点组成,其中存在几种不同的模式。
2.4.1、PSS模式
PSS模式由一个主节点和两个备节点所组成,即Primary+Secondary+Secondary,如图所示。

2.4.2、PSA模式
PSA模式由一个主节点、一个备节点和一个仲裁者节点组成,即Primary+Secondary+Arbiter,如图所示。

其中,Arbiter节点不存储数据副本,也不提供业务的读写操作。Arbiter节点发生故障不影响业务,仅影响选举投票。
2.4.3、PSH模式
PSH模式由一个主节点、一个备节点和一个隐藏节点组成,即Primary+Secondary+Hidden,如图所示。

其中,Hidden节点对业务不可见,同时无法被选举为主节点。一般利用Hidden节点来执行数据备份任务,可以避免备份对业务性能产生影响。
3、实时复制
3.1、oplog复制
在副本集架构中,主节点与备节点之间是通过oplog来同步数据的,这里的oplog是一个特殊的固定集合,当主节点上的一个写操作完成后,会向oplog集合写入一条对应的日志,而备节点则通过这个oplog不断拉取到新的日志,在本地进行回放以达到数据同步的目的。
如果我们将oplog看作缓冲队列,那么整个复制过程就是一个典型的”生产者-消费者“模式的应用。如图所示。

这里的主节点就是生产者,负责向自身的oplog队列中写入增量日志,也就是产生数据变更的记录。备节点则作为消费者一方,不断通过“pull”的方式拉取到这些增量日志进行消费。由于日志会不断增加,因此oplog被设计为固定大小的集合,它本身就是一个特殊的固定集合(capped collection),当oplog的容量达到上限时,旧的日志会被滚动删除。
一个典型的oplog如下所示:

字段说明见下表。

- ts字段描述了oplog产生的时间戳,可称之为optime。
- optime是备节点实现增量日志同步的关键,它保证了oplog是节点有序的,其由两部分组成:
- 当前的系统时间,即UNIX时间至现在的秒数,32位。
- 整数计时器,不同时间值会将计数器进行重置,32位。
- optime属于BSON的Timestamp类型,这个类型一般在MongoDB内部使用。
既然oplog保证了节点级有序,那么备节点便可以通过轮询的方式进行拉取,这里会用到可持续追踪的游标(tailable cursor)技术,如图所示。

每个备节点都分别维护了自己的一个offset,也就是从主节点拉取的最后一条日志的optime,在执行同步时就通过这个optime向主节点的oplog集合发起查询。为了避免不停地发起新的查询链接,在启动第一次查询后可以将cursor挂住(通过将cursor设置为tailable)。这样只要oplog中产生了新的记录,备节点就能使用同样的请求通道获得这些数据。
tailable cursor只有在查询的集合为固定集合时才允许开启。
通过db.currentOp命令可以看到具体的实现,代码如下:


local.oplog.rs指向了oplog集合,它存在于本地的local数据库中。local数据库里面的集合不会被同步到其他节点,而且除了oplog, local库还包含一些具有特殊用途的集合,具体如下。
local.system.replset:用来记录当前副本集的成员。local.startup_log:用来记录本地数据库的启动日志信息。local.replset.minvalid:用来记录副本集的跟踪信息,如初始化同步需要的字段。
3.2、幂等性
每一条oplog记录都描述了一次数据的原子性变更,对于oplog来说,必须保证是幂等性的。也就是说,对于同一个oplog,无论进行多少次回放操作,数据的最终状态都会保持不变。比如在一些原子性操作更新中,我们用 i n c 来使字段自增,这个操作就不是幂等的,对文档字段多次执行 inc来使字段自增,这个操作就不是幂等的,对文档字段多次执行 inc来使字段自增,这个操作就不是幂等的,对文档字段多次执行inc操作,每次都会产生新的结果。这些非幂等的更新命令在oplog中通常会被转换为$set操作,这样无论执行了多少次,文档的最终状态始终与第一次执行的效果一样。
$inc操作,代码如下:
{
"$inc": {"count": 1}
}
在oplog中转换为$set操作,直接写入变更后的值,代码如下:
{
"$set": {"count": 199}
}
3.3、复制延迟
由于oplog集合是有固定大小的,因此存放在里面的oplog随时可能会被新的记录冲掉。如果备节点的复制不够快,就无法跟上主节点的步伐,从而产生复制延迟(replication lag)问题。这是不容忽视的,一旦备节点的延迟过大,则随时会发生复制断裂的风险,这意味着备节点的optime(最新一条同步记录)已经被主节点老化掉,于是备节点将无法继续进行数据同步。
为了尽量避免复制延迟带来的风险,我们可以采取一些措施,比如:
- 增加oplog的容量大小,并保持对复制窗口的监视。
- 通过一些扩展手段降低主节点的写入速度。
- 优化主备节点之间的网络。
- 避免字段使用太大的数组(可能导致oplog膨胀)。
oplog集合的大小
oplog集合的大小可以通过参数replication.oplogSizeMB设置,对于64位系统来说,oplog的默认值为:
oplogSizeMB = min(磁盘可用空间*5%, 50GB)
对于大多数业务场景来说,很难在一开始评估出一个合适的oplogSize,所幸的是MongoDB在4.0版本之后提供了replSetResizeOplog命令,可以实现动态修改oplogSize而不需要重启服务器。
3.4、初始化同步
在开始时,备节点仍然需要向主节点获得一份全量的数据用于建立基本快照,这个过程就称为初始化同步(initial sync)。在MongoDB 3.4版本之后对于初始化同步做了不少改进,我们来看看它是怎么完成的:
- 备节点记录当前的同步optime=t1(来自主节点的同步时间戳),进入STARTUP2状态。
- 从主节点上复制所有非local数据库的集合数据,同时创建这些集合上的索引。
在这个过程中,备节点会开启另外一个线程,将集合复制过程中的增量oplog(t1之后产生)也复制到本地。
- 将拉取到t1之后的增量oplog进行回放,在完成之前,节点一直处于RECOVERING状态,此时是不可读的。
- oplog回放结束后,恢复SECONDARY状态,进入正常的增量同步流程。
最关键的一点就是,在全量复制过程中同时拉取了增量oplog,因此我们不需要担心在复制完成之后主节点上的t1oplog记录被冲掉,而导致初始化同步失败,这大大提升了该过程的性能和可靠性。
初始化同步对主节点仍然会有一定的性能影响,因此在执行初始化同步之前需要考量当前系统的压力情况,尽量选择在业务不繁忙时进行。
同步源
在前面的描述中,笔者只提到了备节点和主节点之间的复制,但实际上MongoDB是允许通过备节点进行复制的,这会发生在以下的情况中。
- 在settings.chainingAllowed开启的情况下,备节点自动选择一个最近的节点(ping命令时延最小)进行同步。
- 使用replSetSyncFrom命令临时更改当前节点的同步源,比如在初始化同步时将同步源指向备节点来降低对主节点的影响。
settings.chainingAllowed选项默认是开启的,也就是说默认情况下备节点并不一定会选择主节点进行同步,这个副作用就是会带来延迟的增加,你可以通过下面的操作进行关闭:
cfg = rs.confg()
cfg.settings.chainingAllowed = false
rs.reconfig(cfg)
尽管存在备节点向备节点同步数据的情况,笔者仍然选择将主节点同步作为主要的场景描述,相比之下这样更加容易理解。
3.5、数据回滚
由于复制延迟是不可避免的,这意味着主备节点之间的数据无法保持绝对的同步。当副本集中的主节点宕机时,备节点会重新选举成为新的主节点。那么,当旧的主节点重新加入时,必须回滚掉之前的一些“脏日志数据”,以保证数据集与新的主节点一致。主备复制集合的差距越大,发生大量数据回滚的风险就越高。
对于写入的业务数据来说,如果已经被复制到了副本集的大多数节点,则可以避免被回滚的风险。应用上可以通过设定更高的写入级别(writeConcern:majority)来保证数据的持久性。
这些由旧主节点回滚的数据会被写到单独的rollback目录下,必要的情况下仍然可以恢复这些数据。
4、自动故障转移
在故障转移场景中,我们所关心的问题是:
- 备节点是怎么感知到主节点已经发生故障的?
- 如何降低故障转移对业务产生的影响?
下面来看一些细节。
下图是一个PSS(一主两备)架构的副本集,主节点除了与两个备节点执行数据复制,三个节点之间还会通过心跳感知彼此的存活。

一旦主节点发生故障以后,备节点将在某个周期内检测到主节点处于不可达的状态,此后将由其中一个备节点事先发起选举并最终成为新的主节点,如图所示。

一个影响检测机制的因素是心跳,在副本集组建完成之后,各成员节点会开启定时器,持续向其他成员发起心跳,这里涉及的参数为heartbeatIntervalMillis,即心跳间隔时间,默认值是2s。如果心跳成功,则会持续以2s的频率继续发送心跳;如果心跳失败,则会立即重试心跳,一直到心跳恢复成功。
另一个重要的因素是选举超时检测,一次心跳检测失败并不会立即触发重新选举。实际上除了心跳,成员节点还会启动一个选举超时检测定时器,该定时器默认以10s的间隔执行,具体可以通过electionTimeoutMillis参数指定:
- 如果心跳响应成功,则取消上一次的electionTimeout调度(保证不会发起选举),并发起新一轮electionTimeout调度。
- 如果心跳响应迟迟不能成功,那么electionTimeout任务被触发,进而导致备节点发起选举并成为新的主节点。
因此,在electionTimeout任务中触发选举必须要满足以下条件:
- 当前节点是备节点。
- 当前节点具备选举权限。
- 在检测周期内仍然没有与主节点心跳成功。
整个选举切换的逻辑如图所示。

在MongoDB的实现中,选举超时检测的周期要略大于electionTimeoutMillis设定。该周期会加入一个随机偏移量,大约在10~11.5s,如此的设计是为了错开多个备节点主动选举的时间,提升成功率。
业务影响评估
- 在副本集发生主备节点切换的情况下,会出现短暂的无主节点阶段,此时无法接受业务写操作。如果是因为主节点故障导致的切换,则对于该节点的所有读写操作都会产生超时。如果使用MongoDB 3.6及以上版本的驱动,则可以通过开启retryWrite来降低影响。
- 如果主节点属于强制掉电,那么整个Failover过程将会变长,很可能需要在Election定时器超时后才被其他节点感知并恢复,这个时间窗口一般会在12s以内。然而实际上,对于业务呼损的考量还应该加上客户端或mongos对于副本集角色的监视和感知行为(真实的情况可能需要长达30s以上)。
- 对于非常重要的业务,建议在业务层面做一些防护策略,比如设计重试机制。
5、搭建副本集
5.1、安装副本集
假设已经完成了MongoDB单节点的安装,并且环境变量path中已经包含了MongoDB执行程序的配置。接下来我们需要为副本集定义一个名称,例如myReplSet。
1.准备安装目录

2.配置文件
执行cd/opt/work/mongoReplSet/进入安装目录。编辑配置文件mongo.conf,内容如下:

上述配置仅包含一些公共的配置,由于每个副本集成员会使用不同的端口、数据目录,我们将在命令行中进行指定。
3.创建keyfile


mongo.key采用随机算法生成,用作节点内部通信的密钥文件。
4.启动副本集成员
执行mongod程序,启动3个副本集成员,代码如下:

除了keyFile、config文件,我们还指定了几个参数,分别如下。
--port:数据库的监听端口。--dbpath:数据的存储目录。--logpath:数据库进程的日志文件路径。
执行命令之后,可以看到启动成功的输出日志:

通过netstat命令同样可以看到启动的几个端口,输出如下:

5.初始化配置
连接其中一个成员节点,并执行初始化命令,代码如下:

此处,cfg._id表示的是副本集的名称(myReplSet),该值必须和副本集成员启动时指定的–replSet参数保持一致。members则表示当前副本集的成员列表,包括每个成员的主机IP、端口号。
使用rs.initiate命令执行副本集的初始化,当前成员会自动向其他成员同步该配置,之后这些成员在内部完成选举。
6.查看状态
执行db.isMaster命令,用于查看副本集的其他节点,代码如下:

从输出上看,当前节点已经成为主节点(“ismaster”:true),而hosts字段也展示了整个副本集的所有成员。
5.2、创建用户
由于使用了–keyFile作为成员节点的启动参数,此时MongoDB会启用鉴权(相当于–auth),因此在操作数据之前需要创建用户,代码如下:

在开启鉴权的情况下,MongoDB允许创建首个用户。一旦数据库中存在用户,所有的操作就必须经过鉴权了。
副本集之间的用户数据会自动进行同步,因此可以使用同一个用户在任一成员节点登录。
5.3、写入数据
登录主节点,向test集合写入一条数据,代码如下:

登录某个备节点,查看test集合,可发现新增的数据已经同步,如下:

5.4、主备节点切换
接下来,验证副本集的主备节点切换功能,登录主节点并执行stepDown命令,代码如下:
myReplSet:PRIMARY> rs.stepDown()
如果执行成功,则当前节点将会降备,并开启新一轮的选举。通过多次执行isMaster命令可以确认最后的选举结果,代码如下:

从结果中可以看出,在主备节点切换之后,127.0.0.1:27002成为新的主节点。
6、检查复制的延迟情况
由于分布式环境中的各种不确定性,因此对副本集的成员状态、复制延迟状态进行检查就变得非常重要。
6.1、rs.status命令
MongoDB对复制成员的监视可以使用rs.status命令,我们可以登录任一节点进行查询,代码如下:




members一列体现了所有副本集成员的状态,主要如下。
health:成员是否健康,通过心跳进行检测。state/stateStr:成员的状态,PRIMARY表示主节点,而SECONDARY则表示备节点,如果节点出现故障,则可能出现一些其他的状态,例如RECOVERY。uptime:成员的启动时间。optime/optimeDate:成员最后一条同步oplog的时间。optimeDurable/optimeDurableDate:成员最后一条同步oplog(写入Journal日志)的时间。pingMs:成员与当前节点的ping时延。syncingTo:成员的同步来源。
6.2、查看复制延迟
如果希望查看当前节点oplog的情况,则可以使用rs.printReplicationInfo命令,代码如下:

这里清晰地描述了oplog的大小、最早一条oplog以及最后一条oplog的产生时间,log length start to end所指的是一个复制窗口(时间差)。通常在oplog大小不变的情况下,业务写操作越频繁,复制窗口就会越短。
在节点上执行rs.printSlaveReplicationInfo命令,可以一并列出所有备节点成员的同步延迟情况,代码如下:


3272

被折叠的 条评论
为什么被折叠?



