6804 字
34 分钟
Raft 共识 - Redis Sentinel / K8s etcd 共识机制详解

本文行文:为什么主从复制不够、需要共识 → Raft 怎么把共识拆成三个子问题 → etcd 如何把它做成生产级实现 → Redis Sentinel 为什么只是”Raft-like” → MySQL 为什么很少谈共识。

1. 共识机制#

简介#

为什么需要:主从复制的天花板#

前面的文章里,MySQL 高可用靠 binlog 复制 + 外部选主,Redis 高可用靠主从 + 哨兵。它们的共同点是:复制解决”数据有几份”,但”谁说了算”始终悬而未决。

问题只有主从复制时需要共识时
谁是主?配置里写死的,或由外部组件(脚本、MHA Manager、哨兵)裁决由节点之间投票裁决,不依赖任何外部单点
主库挂了谁知道?监控系统(可能误判、可能自己挂了)多数派失联即判定,判定本身也经过共识
新主数据全不全?人工或脚本比较位点,容易选错选举规则内建”日志至少不落后于投票者”的约束
网络分区怎么办?两侧可能各自认为自己是主 → 脑裂只有拿到多数派的一侧能继续服务,另一侧自动只读
已确认的写入会丢吗?异步复制下会丢(RPO > 0)多数派确认后才提交,已确认的写入不会丢

复制提供副本,共识提供”唯一的真相”。只要系统里存在”多个副本 + 需要自动决策”,就迟早要面对共识问题:

  • etcd / Consul / ZooKeeper 存的是”集群的元数据”,本身就必须强一致,否则 K8s 的调度、租约、分布式锁全部失效;
  • Redis Sentinel 要在多个哨兵之间决定”谁是新主”,本质是一次配置共识;
  • MySQL MGR 要通过组通信决定”这个事务是否被接受且顺序固定”,本质是一次日志共识。

Paxos:共识的理论起点#

Paxos(Lamport, 1998)是第一个被严格证明正确的共识算法,围绕三个角色:

角色职责
Proposer提出提案(编号 + 值)
Acceptor对提案投票(承诺 + 接受),多个 Acceptor 组成多数派
Learner学习已被选定的值

核心是两阶段:

  1. Prepare 阶段:Proposer 用编号 n 询问多数派 Acceptor:“是否已接受过编号小于 n 的提案?“Acceptor 承诺不再接受编号小于 n 的提案,并返回它已接受的最高编号提案的值。
  2. Accept 阶段:Proposer 若发现有人已接受过某值,就必须沿用那个值(这是安全性的关键);否则可用自己的值。向多数派发送 Accept 请求,多数派接受即提案被选定。

于是得到共识的两个基本事实:

  • 多数派交集:任意两个多数派必然有交集,因此”已被选定的值”一定会被后续 Proposer 看到并沿用,不会出现两个不同的值同时被选定;
  • 半数才可推进:少于多数派的任何集合都无法独立做出决定,这是”防脑裂”的数学基础。

Paxos 的问题不在正确性,而在可理解性与工程化:

  • 原始 Paxos 只描述了”选定一个值”,而工程需要的是连续决策的日志(Multi-Paxos),其中的 Leader 选举、日志空洞填补、成员变更都要额外设计;
  • 论文晦涩,实现差异极大,几乎没有两个团队的 Paxos 实现长得一样;
  • 缺少”领导者”这一显式角色时性能很差(每写一条日志都要两轮往返)。

Raft:为可理解性而生的共识#

Raft(Ongaro & Ousterhout, 2014)的目标很直白:在功能与性能上与 Multi-Paxos 等价,但把问题拆成几个可独立理解、可独立实现的部分。

Raft is a consensus algorithm that is designed to be easy to understand. It’s equivalent to Paxos in fault-tolerance and performance.

相比 Paxos,Raft 做的关键工程化选择:

设计选择带来的好处
强领导(Strong Leader):日志只能由 Leader 流向 Follower把”多 Proposer 竞争”简化为”选出一个 Leader 后顺序追加”,问题分解变得自然
随机化选举超时用极简的机制打破投票平局,避免复杂的优先级协议
任期(Term)作为逻辑时钟所有过期信息都能用”任期号更小”一概拒绝,无需额外时序假设
日志连续性约束 + 提交规则状态机复制(Replicated State Machine)的正确性可以被逐步论证

Raft 共识#

对于一个复杂的共识问题,斯坦福大学的 Ongaro 将其拆分成三个子问题:

  1. Leader Election(领导者选举):集群中必须有一个 Leader,且任意时刻至多一个;
  2. Log Replication(日志复制):Leader 接收客户端请求,作为日志条目复制到多数节点后提交;
  3. Safety(安全性):任何已提交的日志条目都不会被覆盖或丢失,状态机的执行顺序在所有节点上一致。

这三个子问题加上”成员变更(Membership Change)“,就构成了完整可用的 Raft。下面逐个展开。

角色与任期#

每个节点在任意时刻处于三种角色之一:

角色行为
Leader处理所有客户端写请求,向 Follower 发送心跳与日志
Follower被动响应:收到投票请求就投票,收到日志就追加
Candidate选举期间的临时角色:向其他节点拉票

任期(Term) 是单调递增的整数,充当逻辑时钟:

  • 每次选举开始,任期 +1;
  • 任期号承载在每一次 RPC 里;任何节点收到比自己任期更大的请求,立即更新自己的任期并转为 Follower;
  • 收到比自己任期更小的请求,直接拒绝(并回传自己的任期,让对方知难而退)。

由于”同一任期最多一个 Leader”,所有一致性判断都可以归结为”任期号大小比较”,不需要假设时钟同步。

子问题一:Leader Election#

  1. 集群启动或 Leader 失联时,Follower 在 election timeout(通常 150~300ms) 内没收到心跳,就转为 Candidate;
  2. Candidate 任期 +1,投自己一票,向所有节点并发发送 RequestVote;
  3. 节点在一个任期内只投一票(先到先得),并在”候选人的日志至少和自己一样新”时才投票;
  4. 拿到多数派票的 Candidate 成为 Leader,立即发送心跳宣告权威;
  5. 如果出现平票(常见于多个节点同时超时),本轮无人当选,各自等待随机化的选举超时后重试——随机化是打破僵局的关键,把活锁概率压到可忽略。
RequestVote(term, candidateId, lastLogIndex, lastLogTerm) → (term, voteGranted)
AppendEntries(term, leaderId, prevLogIndex, prevLogTerm, entries[], leaderCommit)
→ (term, success, conflictIndex, conflictTerm)

Raft 只有这两个 RPC。AppendEntries 同时承担日志复制和心跳两个职责(entries 为空时就是心跳)。

子问题二:Log Replication#

一次完整的写入:

  1. 客户端把命令发给 Leader;
  2. Leader 把命令追加为本地日志条目,记录 (index, term, command);
  3. Leader 向所有 Follower 发 AppendEntries;
  4. 当该条目被复制到多数节点(含 Leader 自己)时,Leader 把它提交(commit)并应用到状态机,然后把结果返回客户端;
  5. 后续的心跳会携带 leaderCommit,Follower 据此知道哪些条目已提交,并按顺序应用到自己的状态机。

两条关键不变式:

  • Log Matching Property:如果两个日志在相同 index 上的条目 term 相同,则它们及之前的所有条目完全相同;
  • 状态机安全:Leader 只能提交”自己任期内”的条目;跨任期的旧条目要等本任期的新条目提交后,才被间接提交(这是 Raft 论文中容易被忽略、但实现时极易出错的一条规则)。

日志不一致的修复:Follower 拒绝不一致的 AppendEntries 后,Leader 用 nextIndex 逐步回退(论文原版是每次减一,实现上通常用 conflictIndex 一次跳过大段冲突日志),直到找到匹配点,再从这里开始覆盖 Follower 上所有冲突条目——Follower 上未提交的条目以 Leader 为准。

子问题三:Safety#

Raft 用五条规则保证”已提交的永不丢失、状态机执行顺序一致”:

规则内容防住的问题
选举安全同一任期至多一个 Leader(每节点每任期一票 + 多数派交集)双主/脑裂
选举限制只有”日志足够新”的节点才能当选落后节点当主导致数据被覆盖
提交规则只有多数派复制成功才提交;且只提交本任期条目已提交日志被回滚
日志匹配同 index 同 term 的日志前缀完全一致日志分叉
状态机安全所有节点按相同顺序应用相同日志副本间结果不一致

持久化要求:currentTerm、votedFor、日志条目在响应 RPC 前必须落盘(fsync)。只把 commitIndex、lastApplied 放内存是可以的(重启后从 Leader 重建)。忘了持久化 votedFor,就会在重启后投出两票,直接破坏选举安全性——这是自研 Raft 最常见的翻车点。

子问题四:成员变更#

集群运维中增删节点不能靠”停集群改配置”,否则配置不一致的瞬间可能出现两个多数派。Raft 的解法是联合共识(Joint Consensus):先进入 C_old,new 联合配置(任何决定需同时在两套配置中取得多数),再切到 C_new。工程实现里更常用的是单节点变更(One-at-a-time):一次只加或减一个节点,此时新旧配置的多数派必然有交集,因而不需要联合配置的复杂度。

etcd 的成员变更(etcdctl member add/remove)就是走这条路径,因此它要求一次只变一个成员,且变更期间集群不应处于故障状态。

共识的代价#

代价说明
延迟每次写要等多数派落盘,延迟 ≥ 多数派中最慢节点的往返 + fsync 时间
可用性上限N 节点的集群只能容忍 (N-1)/2 个故障;跨地域部署时 RTT 会显著放大写延迟
运维复杂度成员变更、快照压缩、磁盘 IOPS 都直接影响服务
规模上限节点越多容错越强但写越慢,因此生产上 3 节点、5 节点为主,很少超过 7 节点

2. K8s etcd#

标准 Raft 的教科书实现

结论先行: etcd 是 Raft 协议的生产级实现,Leader 选举与日志复制深度耦合,写操作必须获得多数派(quorum)确认,强一致性优先。

Raft 核心机制:

  • Leader 选举:集群启动时无 Leader,各节点随机超时后发起选举,请求投票;获得多数票的节点成为 Leader,开始发送心跳维持权威。
  • 日志复制:所有写请求由 Leader 处理,Leader 将日志条目复制到 Follower,当日志被复制到多数节点(quorum)后,该写操作才被提交并应用到状态机。
  • Quorum 计算:quorum = (N / 2) + 1。三节点集群 quorum 为 2,可容忍 1 个节点故障;五节点 quorum 为 3,可容忍 2 个节点故障。奇数节点是生产最佳实践,因为偶数节点在分区时可能均分导致双方都无法达成多数。
  • 故障转移:Leader 故障或网络隔离后,Follower 心跳超时触发新一轮选举,自动选出新 Leader,无需人工干预。在 quorum 恢复之前,etcd 会暂停写入,恢复后自动安全地继续。

etcd 的 Raft 是数据共识——写操作必须经过多数派确认才提交,保障数据的线性一致性。

etcd 在这套理论上的工程化选择#

etcd 用的 raft 库(go.etcd.io/raft)是一个纯粹的 Raft 状态机,不负责网络、磁盘和线程,因此它的边界非常清晰:

层职责说明
raft 库状态机推进、投票、日志序列无 IO、无网络、无锁竞争,便于测试
WAL(Write-Ahead Log)持久化 HardState(term/vote/commit)与日志条目每次变更都 fsync,这是 etcd 写延迟的主要来源
Snapshot + 后端存储定期快照并压缩日志;bbolt 存储状态机快照让日志可以被截断,否则磁盘无限增长
gRPC / etcdserverRPC、租约、watch、事务、权限把 Raft 包装成对外的 KV 服务

参数与行为特征(对应上面的理论):

概念etcd 参数说明
心跳间隔--heartbeat-interval(默认 100ms)Leader 发送心跳的周期
选举超时--election-timeout(默认 1000ms)应为心跳的 5~10 倍,且远大于网络抖动
快照阈值--snapshot-count(默认 100000)每 10 万个已提交事务打一次快照并截断 WAL
成员变更etcdctl member add/remove内部走单节点变更,一次只变一个且不应在故障中执行
请求超时--request-timeout避免长时间阻塞在失联节点上

读写语义:为什么写入只能问 Leader#

  • 写:必须发给 Leader,由它走完整的 Raft 流程。客户端发错节点时会收到重定向提示,官方客户端会自动重试到 Leader。
  • 线性一致读(默认 --consistency=s):走 Raft 日志(ReadIndex 优化后无需真正写日志),保证读到的是最新已提交数据。
  • 串行读(--consistency=l):绕过 Raft 直接从本地状态机读,延迟低但可能读到旧数据(Follower 落后时尤其明显),只在能容忍陈旧的场景使用。
  • Watch:基于 MVCC 的历史版本,可以指定 --rev 回溯,这也是 K8s 各类控制器实现”事件可靠投递”的基础。

用 K8s 的视角理解共识#

K8s 的整个控制面都建在 etcd 的强一致之上:

  • API Server 无状态:多个实例谁都能处理请求,真正的”唯一真相”在 etcd;
  • resourceVersion:来自 etcd 的 MVCC 版本号,是 K8s 乐观并发控制(conflict → 重试)的基础;
  • Leader Election(coordination.k8s.io/Lease):kube-controller-manager、kube-scheduler 通过租约选主,多个副本只有 leader 真正工作——这本身就是把共识当”分布式锁”用;
  • Controller 的期望状态收敛:靠 watch 拿到有序的变更事件流,从而能实现”至少一次”的处理语义。

因此”etcd 挂了 K8s 控制面就瘫痪”不是 bug 而是设计:共识层不允许被绕过。

3. Redis Sentinel#

轻量级“Raft-like”变种

结论先行: Sentinel 的故障转移本质上是一个简化版、以可用性优先的 Raft-like 协议,它只做“选主 + 配置切换”,不参与数据日志的共识复制。

故障转移流程(五步走):

  1. 主观下线(sdown):单个 Sentinel 在 down-after-milliseconds 内没收到 master 的 PING 响应,就单方面标记其“主观下线”。
  2. 客观下线(odown):该 Sentinel 向其他 Sentinel 发起询问,当超过 quorum(通常为多数)的 Sentinel 都认为 master 不可达时,才判定“客观下线”。这一步是分布式共识的入口。
  3. Leader Sentinel 选举:确认 odown 后,Sentinel 之间基于 Raft-like 投票选出一个 Leader,由它主导后续操作。投票携带 epoch(配置纪元) 递增,获得多数票的 Sentinel 当选。与标准 Raft 的关键差异在于:Sentinel 的选举不依赖日志索引和任期完整性,而是侧重于监控状态和配置更新,省略了日志复制与持久化步骤。
  4. Slave 晋升筛选:Leader 从 slave 中按优先级筛选新 master,标准包括:复制偏移量最大(数据最新)、网络连接正常、运行状态健康。
  5. 配置切换与通知:Leader 向选中的 slave 发送 SLAVEOF NO ONE 使其晋升,其余 slave 重定向到新 master,并通过 Sentinel 的 Pub/Sub 通知客户端新的 master 地址。

Sentinel 的共识是 “故障检测共识 + Leader 选举”,而非数据复制共识。它允许在网络分区期间牺牲强一致性换取可用性,这也是 Jepsen 测试中 Sentinel 在分区时可能丢数据的原因。

把 Sentinel 和 Raft 逐条对照#

Raft 的概念Sentinel 里的对应物差异与后果
任期 term配置纪元 epoch(current_epoch / configEpoch)同样是单调递增的逻辑时钟,用来拒绝过期信息、收敛配置
RequestVoteSENTINEL is-master-down-by-addr(带 runid 表示要投票)投票语义相同:每个 Sentinel 在每个纪元只投一票
心跳每秒 PING心跳失败计数达到阈值 → sdown → 询问 → odown
election timeoutdown-after-milliseconds + 随机延迟随机化是为了避免同时发起投票
日志复制不存在不复制任何数据,只传播”谁是主”这一条配置
提交多数派判定 odown 的 quorum + 选举 leader 的多数派quorum 决定”是否认为下线”,多数派决定”谁有权切换”
持久化sentinel.conf 里的 epoch 与配置比 etcd 弱:配置落盘但不是 WAL 式的严格持久化
读一致性无客户端可能读到旧主(切主瞬间),Sentinel 不提供任何线性一致保证

为什么说它”牺牲一致性换可用性”#

标准 Raft 的核心是”多数派确认才提交”,因此已提交的数据永远不会丢。Sentinel 没有这一层:

  1. 数据复制仍是异步的:Redis 主从复制的 offset 差距意味着新主可能缺一段写入,切换后会永久丢失;
  2. 共识只覆盖”谁是主”,不覆盖”数据是否到位”:Sentinel 可以正确选出新主,但无法保证新主拥有全部数据;
  3. 分区期间宁可切换也不阻塞:多数派一侧照常切换并对外服务,这是可用性优先的明确取舍;
  4. 没有日志安全性的等价规则:它不能用”日志至少不落后”来约束候选人——只能比较 offset,而 offset 并非强一致量。

所以 Jepsen 之类的测试中,Sentinel 在分区场景下出现丢数据(已确认写入丢失)是设计使然,而非实现 bug。想要”零丢失”的 Redis 高可用,需要的是共识化的复制(例如 Redis 自身的 Raft 类方案、或把强一致元数据放到 etcd/ZK 中)。

4. MySQL?#

同样是数据库,为什么 MySQL 高可用很少谈论“共识机制”?

数据层#

核心原因:MySQL 的传统高可用范式与共识协议的设计目标存在结构性错位。

  1. 传统主从复制的本质是“异步/半同步”。MySQL 默认复制是异步的:主库写完 binlog 就返回成功,不等从库确认。共识协议的起点是“多数派确认才算提交”,这与 MySQL 异步复制的设计哲学根本不同。
  2. 主流 MySQL HA 方案(MHA)是“外部管理器单点决策”。MHA 的故障切换由单个 Manager 进程决策:检测主库失败后,从从库中选数据最新的提升为新主,再补差异日志。Manager 本身不是共识集群,它挂掉后集群仍可运行,但无法自动切换。这种架构的共识“发生在外部管理器”,而非节点之间的投票。
  3. MGR是唯一引入 Paxos 的官方方案,但工程成熟度劝退了大规模采用。MGR 底层基于 Paxos 协议做认证和冲突检测,理论上可提供零数据丢失的自动选主。但实际生产中暴露了硬伤:
    • Paxos 消息未持久化到磁盘,MySQL 重启后认证数据库缺失,容易导致持续处理持久化 Paxos 消息时出现不一致状态。
    • 此外,MGR 在网络不佳或大事务场景下崩溃过且难以修复,导致不少团队研究后仍退回主从模式。
维度Redis Sentinel / etcd RaftMySQL 传统 HA
共识对象节点状态 / 配置变更(Sentinel);数据日志(etcd)传统方案不涉及共识;MGR 共识数据但工程不成熟
决策主体Sentinel 集群 / Raft 集群外部 Manager(单点) / MGR 集群(Paxos,但落盘缺陷)
一致性目标可用性优先(Sentinel)/ 强一致(etcd)异步/半同步,RPO>0 可接受
工程成熟度生产验证充分MHA 成熟但无共识;MGR 有共识但稳定性不足

MySQL 生态的历史惯性选择了 “外部管理器 + 异步复制” 的轻量路径;当真正尝试把 Paxos 塞进复制层时(MGR),又因持久化缺陷和稳定性问题未能成为主流讨论的默认选项。

主节点决策#

虽然 MySQL 本身不采用共识机制,但如果把视野放宽到”谁来决定谁是主”,MySQL 生态其实有三种截然不同的答案:

路线代表共识在哪说明
控制面外包Patroni(PG)、Vitess + etcd/ZK、Orchestrator + Consul外部共识存储数据库自己不实现共识,把”选主 + 配置”交给 etcd/ZK,这正是 Raft 最擅长的小数据强一致场景。这是当前最主流的务实方案
写进复制层MySQL MGR(XCom)组通信的 Paxos 变体8.0 后 XCom 把消息持久化到 mysql 库,早期”重启后不一致”的缺陷已被修复,但运维复杂度、跨地域延迟、大事务下的表现仍限制了普及
认证式同步MariaDB Galera / Percona XtraDB Cluster写集认证(非共识)广播写集 + 冲突认证,没有 Leader 选举也没有日志共识,靠 quorum 拒写防脑裂;因此它是”同步复制”而非”共识复制”,流控与热点冲突是它的固有痛点

顺带回答一个常见困惑:为什么 PostgreSQL 的高可用文档里到处都是 etcd/Consul,而 MySQL 的文档里很少提? 因为 PG 社区更早地把选主问题识别为”共识问题”并外包了(Patroni 事实上成为标准),而 MySQL 社区的历史路径是 MHA/Orchestrator 这类”管理器”方案,共识是被隐式吞掉的。这既是历史惯性,也是运维习惯的差异。

5. 区别#

四种”选主/共识”机制对比#

维度etcd(标准 Raft)Redis Sentinel(Raft-like)MySQL MGR(XCom/Paxos)主从 + 外部管理器(MHA/脚本)
共识对象数据日志(每一条写)仅”谁是主”的配置事务的组内顺序与认证无共识
决策主体节点投票(多数派)Sentinel 投票(多数派)组内多数派原子广播单个外部进程/人
能否自动选主能能能取决于脚本,管理器自身是单点
数据是否会丢不会(已提交即多数派落盘)会(异步复制 + 无数据共识)单主模式基本不会(多数派提交)会(RPO > 0)
脑裂防护少数派无法提交少数派无法完成切换少数派无法提交依赖 STONITH / VIP 隔离,弱
持久化要求term/vote/log 必须 fsync配置文件级依赖组通信层(历史上有缺陷)无
客户端语义线性一致读写、Watch只知道”主在哪”普通 MySQL 协议普通 MySQL 协议
运维复杂度中(磁盘、快照、成员变更)低(≥3 个哨兵进程)高(版本、网络、冲突回滚)低但需人工介入
典型 RTO秒级(选举 1~2 个超时周期)秒~分钟级秒级分钟~小时级

共识谱系#

把”数据要不要经过共识”作为横轴,就能看清各系统的定位:

系统/协议共识层位置说明
Raft / Paxos / ZAB通用共识协议ZAB 是 ZooKeeper 的原子广播协议,与 Paxos 同族,强调”主备顺序”
Multi-Raft(TiDB)数据分片级每个 Region 一个 Raft 组,成千上万个组并行,共识与分片结合
etcd / Consul元数据级小数据量、强一致,是”共识的样板间”
Redis Sentinel控制面级(无数据共识)只共识配置,不共识数据
Redis Cluster控制面级(无数据共识)与哨兵同属”Raft-like”:epoch + 每主每纪元一票 + 多数派,但没有日志与严格的持久化
MySQL MGR复制层级用 Paxos 变体(XCom)做组通信
MySQL 传统 HA无共识管理器/云平台决策
Patroni + etcd/ZK控制面级PostgreSQL 的常见做法:把选主这一”共识问题”外包给专门的共识存储

观察结论:成熟生态的普遍做法是把共识”外包”给专门的共识存储,而不是在数据层重新造一个。 Patroni(PostgreSQL)、Vitess(MySQL)、Kubernetes 都是这个思路。

6. 小结#

  • 共识解决的是”多个副本如何在不依赖外部单点的前提下达成同一个决定”:多数派交集是安全性的数学基础,因此”少于多数派就不能做决定”是防脑裂的根本,也是共识与异步复制最重要的分野。
  • Paxos 提供了理论,Raft 提供了工程。Raft 把共识拆成选举、日志复制、安全性(+ 成员变更)四个可独立验证的部分,用”强 Leader + 随机化选举超时 + 任期逻辑时钟”换来了可实现性与可教学性。
  • 安全性全靠几条规则撑着:每任期一票、候选人日志不能落后、只提交多数派复制成功的条目(且只提交本任期条目)、term/vote/log 必须落盘。少任何一条,“共识”就退化成一个会丢数据的乐观复制协议。
  • etcd 是标准 Raft 的教科书实现,也是”共识被当作基础设施”的最佳范例:K8s 控制面的租约、resourceVersion、watch 语义都建立在它的线性一致性上。
  • Redis Sentinel 只是 Raft-like:它共识”谁是主”,不共识数据,因此设计上就允许分区期间丢数据。这个取舍适合缓存与会话,不适合锁与幂等键。
  • MySQL 传统高可用不依赖共识,因为”外部管理器 + 异步复制”的路径更轻;但当 RPO 要求趋近于 0 时,共识终将回到视野——要么写进复制层(MGR),要么外包给 etcd/ZK(Patroni 模式)。
  • 一句话选型:能不能接受”丢一点数据”,决定了你要的是”复制 + 监控”,还是”共识”。
Raft 共识 - Redis Sentinel / K8s etcd 共识机制详解
https://blog-l7wd3.pages.dev/posts/ha/raft/
作者
L7WD3-Xiao
发布于
2026-09-20
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取