分布式系统设计:CAP/BASE 选型 + 分布式事务四方案 + Raft 共识
分布式事务方案选错,代价不是改几行代码------是重写整个交易链路。我从一个日均千万订单的项目里总结出一张四方案决策树:Seata AT(改得少→性能掉 30%)→TCC(代码 3 倍→但一致性最强)→Saga(长流程)→RocketMQ 事务消息(最简单→只适用异步)。选方案前先想清楚一个问题------你这个场景真的需要强一致吗?
阅读约 16 分钟 | 系列第 11/17 篇
一、CAP 定理:P 必须选,C 与 A 的权衡
CAP 定理:一个分布式系统最多同时满足 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容忍性)中的两个。
| 含义 | 违反后果 | |
|---|---|---|
| C(一致性) | 所有节点同一时刻看到相同数据 | 读到旧数据 |
| A(可用性) | 每个请求都能获得非错误响应 | 服务不可用 |
| P(分区容忍性) | 节点间网络断开后系统仍可运作 | 系统瘫痪 |
P 必须选------网络分区一定会发生(交换机故障、网络延迟、机房断电)。剩下在 C 和 A 之间权衡:
- 金融核心场景 (账户余额、订单状态)→ 选 CP,宁可短暂不可用也不能数据不一致
- 非交易场景 (用户昵称、商品描述)→ 选 AP,最终一致即可
- 不同服务可以有不同的 CAP 选择------核心服务偏 C,外围服务偏 A
场景案例:支付系统的双链路 CAP 选择
同一个支付系统内,不同服务的 CAP 选择可以不同:
| 服务 | CAP 选择 | 网络分区时的行为 | 业务理由 |
|---|---|---|---|
| 订单服务(下单、查询订单) | AP | 继续接受订单,返回"订单处理中";下游恢复后异步同步 | "系统能用"比"数据即时准确"更重要------用户看到"处理中"可以接受,看到"500 错误"会流失 |
| 账户服务(余额扣减、记账) | CP | 拒绝写入,返回"系统繁忙,请稍后重试" | 余额不准是资金安全底线------宁可暂时不可用,绝不能出现"扣了钱但没记账" |
"同一个系统内,不同服务可以做不同的 CAP 选择------核心资金链路选 CP 保一致性,外围展示链路选 AP 保可用性。这不是技术妥协,而是业务建模:CAP 的选择本质上是'这个服务在故障时伤害哪一端'的决策。"
PACELC:CAP 的细化模型
CAP 的局限性在于它只考虑了"发生网络分区时"的取舍------但网络分区并非常态。PACELC 将场景拆分为两类:
| 场景 | 含义 | 权衡 |
|---|---|---|
| P (Partition) | 发生网络分区时 | trade A vs C(同 CAP) |
| E (Else) | 无分区、正常运行中 | trade L (Latency,延迟) vs C (Consistency,一致性) |
典型系统的 PACELC 分类:
| 系统 | PACELC 类型 | 含义 |
|---|---|---|
| DynamoDB、Cassandra | PA/EL | 分区时选可用性;正常时选低延迟(允许读到旧数据) |
| HBase、ZooKeeper | PC/EC | 分区时选一致性;正常时也选一致性(读操作可能等待同步) |
| MongoDB(默认配置) | PA/EC | 分区时选可用性(允许主从切换短暂不一致);正常时选一致性(读主节点) |
"PACELC 的真正价值在于提醒我们:CAP 的 C/A 权衡不是唯一的------即使系统正常运行,延迟与一致性之间也存在取舍。将数据复制到三个副本后才返回成功(强一致),和写入主节点后立即返回(低延迟但可能读到旧数据),是每天都在发生的选择。"
BASE 理论(AP 的实践指导)
Basically Available(基本可用)→ Soft State(允许中间态)→ Eventually Consistent(最终一致性)。做不到强一致性(C),那就保障基本可用 + 最终一致性。
二、分布式事务:四种方案递进
2PC(两阶段提交)
两阶段提交的核心流程------协调者(Coordinator)先问所有参与者"能提交吗?"(Prepare),全票通过后再发"正式提交"(Commit):
sql
客户端 协调者 参与者A 参与者B 参与者C
| | | | |
|--- 提交请求 -------->| | | |
| | | | |
| [Phase 1: Prepare] | | |
| |--- Prepare --------->| | |
| |--- Prepare ----------|-------------->| |
| |--- Prepare ----------|---------------|-------------->|
| | | | |
| |<-- YES --------------| | |
| |<-- YES --------------|-------------->| |
| |<-- YES --------------|---------------|-------------->|
| | | | |
| [Phase 2: Commit] | | |
| |--- Commit ---------->| | |
| |--- Commit -----------|-------------->| |
| |--- Commit -----------|---------------|-------------->|
| | | | |
| |<-- ACK --------------| | |
| |<-- ACK --------------|-------------->| |
| |<-- ACK --------------|---------------|-------------->|
| | | | |
|<-- 提交成功 ---------| | | |
故障场景:任一参与者返回 NO 或超时 → 协调者发 Rollback 给所有参与者。
致命缺陷:① 同步阻塞------Prepare 阶段锁住资源,整个提交完成前其他事务无法操作同一行数据;② 协调者单点故障------Prepare 阶段全部通过后协调者宕机,参与者不知道该提交还是回滚,资源锁永久持有;③ 数据不一致------Commit 阶段协调者仅发出了部分 Commit 请求就宕机,部分参与者提交了、部分没有。
XA 协议是 2PC 的标准实现。金融系统通常不采用 2PC 做跨服务事务------性能代价过高,单点故障风险不可接受。
TCC(Try-Confirm-Cancel)
业务层提供三个方法,将事务控制权从数据库层上移到应用层:
| 阶段 | 动作 | 要求 |
|---|---|---|
| Try | 预留资源 + 校验 | 各服务并行执行,锁定本次事务所需的全部资源 |
| Confirm | 确认执行 | 使用 Try 阶段预留的资源完成业务操作。必须幂等------Confirm 可能被重试 |
| Cancel | 取消释放 | 回滚 Try 阶段的资源预留。必须幂等------Cancel 可能被重试 |
转账示例:账户 A 向账户 B 转账 100 元(TCC 伪代码):
java
// ============ Try 阶段 ============
// 账户服务 A:冻结资金
public void tryDecrease(String accountA, BigDecimal amount) {
// UPDATE account SET available = available - 100, frozen = frozen + 100
// WHERE account_id = 'A' AND available >= 100
// 若 available < 100 → 抛出异常,触发全局 Cancel
}
// 账户服务 B:校验账户状态(不做实际资金变动)
public void tryIncrease(String accountB, BigDecimal amount) {
// SELECT status FROM account WHERE account_id = 'B'
// 若账户不存在或已冻结 → 抛出异常,触发全局 Cancel
}
// ============ Confirm 阶段 ============
// 账户服务 A:实际扣减冻结资金
public void confirmDecrease(String accountA, BigDecimal amount) {
// UPDATE account SET frozen = frozen - 100
// WHERE account_id = 'A' AND frozen >= 100
// 返回受影响行数:若为 0 说明已 Confirm 过(幂等保障),直接返回成功
}
// 账户服务 B:实际增加余额
public void confirmIncrease(String accountB, BigDecimal amount) {
// UPDATE account SET balance = balance + 100 WHERE account_id = 'B'
// 返回受影响行数:若为 0 说明已 Confirm 过(幂等保障),直接返回成功
}
// ============ Cancel 阶段 ============
// 账户服务 A:解冻资金
public void cancelDecrease(String accountA, BigDecimal amount) {
// UPDATE account SET available = available + 100, frozen = frozen - 100
// WHERE account_id = 'A' AND frozen >= 100
// 返回受影响行数:若为 0 说明已 Cancel 过或 Try 未执行(幂等保障),直接返回成功
}
// 账户服务 B:无操作(Try 阶段未冻结任何资源)
public void cancelIncrease(String accountB, BigDecimal amount) {
// 空操作------直接返回成功
}
TCC 核心要点:
- 优点:性能高(各服务并行 Try,无数据库长事务锁);不依赖底层数据库的分布式事务支持
- 缺点:侵入大(每个服务写三套代码);Confirm/Cancel 必须幂等(通过事务状态表 + 唯一约束实现)
- 空回滚:Cancel 先于 Try 到达时,需判断 Try 是否执行过,未执行则直接返回成功------避免对一笔从未开始的事务执行回滚
- 防悬挂:Cancel 比 Try 先执行时,Cancel 记录一条"Cancel 已执行"标记,后续迟到的 Try 检查到此标记后直接拒绝执行
MQ 最终一致性
基于 RocketMQ 事务消息:发送 half 消息(消费者不可见)→ 执行本地事务 → commit/rollback。如果本地事务执行完但未发送 commit(进程 crash),MQ 定期回查上游事务状态。
sql
生产者 RocketMQ 消费者
| | |
|--- 发送 half 消息 --------------------------->| (消息暂存,消费者不可见) |
| | |
|--- 执行本地事务(如:扣减库存) | |
| 成功 → commit / 失败 → rollback | |
| | |
| 【异常分支:本地事务执行完但 commit 未发出------进程 crash】 |
| | |
| MQ 回查:调用生产者提供的 check 回调 |
| <-- checkLocalTransaction(txId) --| |
| -- 返回 COMMIT/ROLLBACK -------->| |
| | |
| |--- 消息对消费者可见 ------------------------>|
| | 消费者执行本地事务 |
适用场景:非核心链路------下单成功后发短信、更新统计表、同步搜索索引等。"允许短暂不一致,但不允许永久不一致。"
Seata AT 模式
一阶段提交业务 SQL + 记录 undo_log → 二阶段全局提交(异步删除 undo_log)或回滚(反向补偿 SQL)。对业务侵入最小------只需在方法上添加 @GlobalTransactional 注解,Seata 自动代理数据源,拦截 SQL 并记录回滚信息。
代价 :① 隔离性较弱------一阶段提交后、二阶段完成前,其他事务可能读到未全局确认的数据(可通过 @GlobalLock + SELECT FOR UPDATE 解决);② 存在性能开销------每个写操作额外生成 undo_log,全局锁在 TC(事务协调者)侧维护。
金融系统选型
| 链路 | 方案 | 原因 |
|---|---|---|
| 核心交易(下单/资金扣划) | TCC | 性能最高,强一致性,Confirm/Cancel 幂等可保障资金安全 |
| 非核心(通知/日志/统计) | MQ 最终一致 + Seata AT | 侵入小,最终一致即可,允许短暂延迟 |
"分布式事务没有银弹,核心是理解每种方案的代价------强一致性 = 性能代价 + 复杂度代价。TCC 最强但也最重,MQ 最终一致最轻但也最弱,Seata AT 居中。选型就是在这根轴上调位置。"
三、Raft 共识算法
Raft 要解决的问题:分布式系统中多个节点如何对一个值达成一致。Raft 将共识问题拆解为三个子问题------Leader 选举 、日志复制 、安全性。
Raft 集群中每个节点处于三种角色之一:Leader (处理所有写请求)、Follower (被动响应)、Candidate(选举中的临时角色)。
① Leader 选举:具体场景还原
初始状态:3 节点集群------A(Leader,term=1)、B(Follower,term=1)、C(Follower,term=1)。选举超时时间随机化为 150-300ms 区间。
css
时刻 T₀:正常运行
A --[心跳 50ms/次]--> B
A --[心跳 50ms/次]--> C
B 和 C 每次收到心跳,重置自己的选举超时计时器
时刻 T₁:A 宕机
心跳停止。B 和 C 的选举超时计时器开始倒计时
时刻 T₂:B 的选举超时触发(B 随机到了 180ms,C 的计时器还剩 40ms)
B 的角色:Follower → Candidate
B 的 term:1 → 2
B 投票给自己:voteCount = 1
B 向 C 发送 RequestVote RPC:
{term: 2, candidateId: B, lastLogIndex: 10, lastLogTerm: 1}
时刻 T₃:C 收到 B 的 RequestVote
C 的判断逻辑:
① B.term (2) >= C.currentTerm (1)?→ 是,C 更新 currentTerm = 2
② C 在当前 term (2) 投过票吗?→ 没有
③ B 的日志至少和自己一样新吗?
- B.lastLogTerm (1) > C.lastLogTerm (1)?→ 否(相等)
- 相等时比较 lastLogIndex:B (10) >= C (10)?→ 是
→ 日志足够新,合格
C 回复:{term: 2, voteGranted: true}
C 重置选举超时计时器(因为收到了更大 term 的 RPC)
时刻 T₄:B 收到 C 的投票
B 的投票数:1(自己)+ 1(C)= 2
2 > 3/2 → 超过半数 → B 当选为 term 2 的 Leader
时刻 T₅:B 开始发送心跳
B --[心跳,term=2]--> C
C 收到心跳,确认 B 为 Leader
C 重置选举超时计时器
竞争选举(Split Vote):若 B 和 C 几乎同时超时,两者都变为 Candidate,各自投票给自己,各得 1 票------均未超过半数。两个 Candidate 各自进入下一轮随机超时等待,先超时者赢得下一轮选举。随机化超时时间是 Raft 选举机制避免活锁的关键设计。
选举关键规则:① 一个 term 内,每个节点最多投一票;② Candidate 的日志必须不比投票者旧(先比较 lastLogTerm,term 相同再比较 lastLogIndex);③ 收到 term 大于自身 term 的任何 RPC → 立即转为 Follower,更新自身 term。
② 日志复制:10 步完整时序
Raft 的日志复制是"多数派确认"机制最核心的体现:
less
前提条件:B 是 term 2 的 Leader,A 已宕机,C 是 Follower
Leader B 的日志:[idx1,t1] [idx2,t1] [idx3,t1] committedIndex=3
Follower C 的日志:[idx1,t1] [idx2,t1] [idx3,t1] committedIndex=3
Step 1: 客户端向 Leader B 发送写请求 → SET X = 5
Step 2: Leader B 将命令追加到本地日志
B 的新日志条目:{index: 4, term: 2, command: "SET X=5"}
(尚未提交------committedIndex 仍为 3)
Step 3: Leader B 向所有 Follower(C 和 A)并行发送 AppendEntries RPC
内容:{term: 2, leaderId: B,
prevLogIndex: 3, prevLogTerm: 1, ← 用于一致性检查
entries: [{index: 4, term: 2, command: "SET X=5"}],
leaderCommit: 3}
Step 4: Follower C 收到 AppendEntries,执行一致性检查
C 检查自己的日志在 index=3 处的 term 是否为 1(prevLogTerm)
→ 是 → 一致性检查通过
→ C 将 entry {index:4, term:2, cmd:"SET X=5"} 追加到自己的日志
→ 回复:{term: 2, success: true}
Step 5: Follower A 已宕机,无回复(Leader B 会持续重试 AppendEntries)
Step 6: Leader B 收到 C 的确认
现在拥有 entry {index:4, term:2} 的节点:B(Leader)+ C(Follower)= 2 个
2 > 3/2 → 超过半数 → 可以提交!
Step 7: Leader B 提交 entry index=4
B 将 entry 应用到状态机:X = 5
B 更新 committedIndex = 4
Step 8: Leader B 返回"写入成功"给客户端
(客户端得到响应------此时 Follower C 尚未提交,但不影响正确性)
Step 9: 下一次心跳中,Leader B 通知 committedIndex=4
Follower C 收到心跳,发现 committedIndex=4 > 自己的 committedIndex=3
→ C 将 entry index=4 应用到状态机:X = 5
→ C 更新 committedIndex = 4
Step 10: 完成。3 个节点中有 2 个持久化了 entry index=4
后续 A 恢复后,Leader B 会通过 AppendEntries 补齐 A 缺失的日志
日志复制的核心:一致性检查 。AppendEntries 中的 prevLogIndex 和 prevLogTerm 是两个关键的校验字段------Follower 会检查自己日志在 prevLogIndex 位置的 term 是否等于 prevLogTerm。若不等,说明 Follower 的日志与 Leader 在某个位置出现了分叉,Leader 会递减 prevLogIndex 逐条回退,直到找到一个一致性交汇点后开始覆盖写入。这个简单的设计保证了一个重要属性:如果两个节点的日志在同一个 index 上有相同的 term,那么它们在这个 index 之前的所有条目完全一致。
③ 安全性
- 选举限制:Candidate 的日志必须"至少和投票者同样新"------先比较 lastLogTerm,term 相同再比较 lastLogIndex。这保证了已提交的 entry 不会在后续任期中丢失
- 提交规则 :Leader 只能提交当前 term的 entry------不能通过提交旧 term 的 entry 来间接提交。这防止了"已提交的 entry 在后续 term 中被覆盖"的异常情况
Paxos vs Raft
| 维度 | Multi-Paxos | Raft |
|---|---|---|
| 可理解性 | 难,论文晦涩 | 易,明确拆为三子问题 |
| Leader | 可有多个 Proposer | 严格单一 Leader(强 Leader 模型) |
| 日志 | 允许不严格连续(可并发提交,存在空洞) | 严格连续递增,不允许空洞 |
| 成员变更 | 需单独处理(Joint Consensus / 单步变更) | 内置 Joint Consensus 机制,更工程化 |
| 业界采用 | Google Spanner、OceanBase、Chubby | etcd、Consul、TiKV、Nacos |
为什么 OceanBase 选 Multi-Paxos? ① OceanBase 起步早(2010 年),Raft 论文 2013 年才发表------时间窗口决定了技术栈起点 ② Multi-Paxos 允许日志不严格连续(空洞),适合分布式数据库的并发事务提交------多个事务可以在不同 index 位置并发写入,性能上限更高 ③ Google Spanner 也基于 Paxos------金融级数据库更信任已有大规模生产验证的 Paxos 系算法。这不是"Paxos 比 Raft 好",而是"已有基础设施和团队经验决定了技术路线"。
"Paxos 和 Raft 是等价的------都能实现分布式共识,Raft 更易懂。选型是历史的,原理是共通的------多数派确认 + Leader 协调 + 日志复制。理解了一个,另一个的核心思想也能看懂。"
四、分布式设计常用方案
| 设计 | 方案 | 关键点 |
|---|---|---|
| 分布式 ID | 雪花算法 | 1bit + 41bit时间戳 + 10bit机器 + 12bit序列。时钟回拨→阻塞等待或使用 sequence 上限 |
| 幂等 | msgId 去重 + 状态机 + 唯一约束 | 消息可重投,消费必须幂等 |
| 分布式锁 | Redisson 看门狗 | SET NX EX → Redisson 自动续期 → RedLock 争议大 |
| 分布式 Session | Redis 集中存储 | Spring Session + Redis,各节点无状态 |
雪花算法时钟回拨处理
时钟回拨是雪花算法的经典难题------无论 NTP 校时、虚拟机迁移还是手动调整,时钟都可能"跳回"过去的时间点,导致生成重复 ID。业界三种应对策略:
- 阻塞等待(美团 Leaf、默认雪花算法):若回拨时间较短(< 5ms),阻塞等待时钟追上回拨前的时间点后继续生成。简单有效,但不适用于较大回拨。
- 备用 workerId 位(百度 UidGenerator):RingBuffer 预先生成一批 ID 并缓存。若检测到时钟回拨,在原有 workerId 的备用位上补偿一个增量,生成不同的序列起点。避免了对外部时钟的强依赖。
- 抛异常拒绝服务:若时钟回拨超过阈值(如 100ms),直接拒绝生成 ID,等待人工介入。适用于对 ID 重复零容忍的强一致性场景。
"生产环境的雪花算法实现不能忽视时钟回拨------它不会每天发生,但发生一次且未处理,造成的 ID 重复就是数据事故。"
五、从理论到实践:三个经典系统设计推演
以下三个经典系统设计题将本节所讲的分布式 ID 生成、Redis 数据结构、MQ 削峰、幂等设计等知识点串联为完整方案,展现"原理→架构→代码"的完整链路。
5.1 短链系统(TinyURL)
需求澄清与数据量估算(BOTEC)
- 功能:长 URL → 7 位短链;访问短链 → 302 重定向
- 预估:每天 100 万新短链,读 QPS 约 10 万
- 存储(5 年):18 亿条 × 131B/条 ≈ 236 GB
核心设计:发号器 + Base62
不要用 Hash(碰撞需处理)或 UUID(太长)。用 Snowflake 生成 64 位唯一 ID → 转 62 进制(0-9a-zA-Z)→ 7 位短码:
yaml
Snowflake ID: 6852435064793841664 → Base62: 3dK3k9M
| 方案 | 唯一性 | 长度 | 说明 |
|---|---|---|---|
| 发号器+Base62 | ✅ 天然唯一 | 7位 | 只需存ID |
| MD5截取前7位 | ❌ 碰撞需处理 | 7位 | 需额外重试逻辑 |
| UUID截取 | ✅ | 22+位 | URL本身太长 |
存储与重定向链路
arduino
用户访问 http://short.cn/3dK3k9M
→ Nginx 负载均衡
→ 短链服务:
① Caffeine 本地缓存(热点短链,1万条,5min过期)
② 未命中 → Redis(短链→长URL)
③ 未命中 → MySQL(B+Tree索引在shortCode列)→ 回写Redis+Caffeine
④ 返回 302 Location: 长URL
| 问题 | 方案 |
|---|---|
| 发号器单点 | Snowflake 天然分布式,各机器不同 workerId 独立发号 |
| 时钟回拨 | 阻塞等待 < 备用位补偿 < 抛异常+人工介入 |
| 热点短链 | Caffeine + Redis 主从 + CDN 多级缓存 |
| 过期清理 | 定时任务标记过期 → 归档冷存储 |
| 防恶意扫描 | Sentinel 令牌桶,单 IP 每秒最多 100 次 |
5.2 秒杀系统
秒杀与普通下单的核心差异:并发量从几千到几十万 QPS、流量从均匀分布变为瞬时峰值、库存竞争从低到极高。
漏斗模型四层架构
erlang
第一层:CDN + 静态化(99% 流量挡在这里)
├── 秒杀页面纯静态 HTML,CDN 缓存
└── 秒杀按钮到时间后 JS 发请求
第二层:网关限流(Nginx / Gateway)
├── 令牌桶限流:单 IP 每秒 10 次
└── 验证码/答题:分散请求(人机识别+手动减速)
第三层:应用层削峰(MQ 异步)
├── 请求直接发 MQ → 快速返回"排队中"
└── 消费者逐条处理 → Redis 扣库存 → 成功则创建订单
第四层:数据库层(最终落地)
├── 库存扣减用 Redis Lua 脚本保证原子性
└── 订单持久化到 MySQL(异步写入)
核心问题:怎么保证不超卖?
lua
-- Redis Lua 脚本:原子扣减库存
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then return -1 end
if stock < tonumber(ARGV[1]) then return -1 end
redis.call('decrby', KEYS[1], ARGV[1])
return stock - tonumber(ARGV[1])
为什么用 Redis 而不是 MySQL? MySQL 单行更新的行级锁竞争上限约 1000-2000 TPS,秒杀场景下成为全局瓶颈。Redis 单线程模型天然免疫------单机 10 万+ QPS。
为什么不用 JVM 锁? 秒杀通常是多实例部署,synchronized/ReentrantLock 是进程级别锁,跨实例无效。Redis 是共享中间件,天然支持分布式。
防重复下单
Redis 分布式锁:SET order:lock:{userId}:{productId} NX EX 30------拿到锁才能进入下单流程。仅用于瞬时去重(秒杀窗口 30s),不是长期幂等方案。
秒杀结束后同步数据库
MQ 消费者创建订单 → 写入 MySQL。定时任务对账:Redis 总扣减数 vs MySQL 订单数,发现差异自动补单/退款。
5.3 实时排行榜系统
需求:100 万用户,实时更新积分,支持 Top 100 查询 + 查个人排名
存储选型
| 方案 | Top 100 | 查个人排名 | 更新积分 |
|---|---|---|---|
MySQL ORDER BY score DESC LIMIT 100 |
100万行排序→慢 | 全表扫描→慢 | 单行UPDATE→快 |
Redis ZSet ZADD/ZREVRANGE/ZREVRANK |
O(log N+M)≈毫秒级 | O(log N)≈毫秒级 | O(log N)≈微秒级 |
果断选 Redis ZSet。跳表按 score 排序天然适合排行榜场景------取 Top 100 只需访问最高分段的最右侧 100 个节点。MySQL 即使给 score 加索引,ORDER BY score DESC LIMIT 100 仍需全索引扫描。
积分相同怎么排?
Redis ZSet score 相同时默认按 member 字典序。如需"先达到该分数的排前面",将 timestamp 编码到 score 的小数部分:
ini
score = 积分 × 10^10 + (Long.MAX_VALUE - timestamp)
架构
markdown
用户完成交易 → MQ(积分变动事件)
→ 消费者:
① ZADD rank:daily 积分 userId(每日榜)
② ZINCRBY rank:weekly 积分 userId(每周榜累加)
③ ZINCRBY rank:monthly 积分 userId(月度榜累加)
→ 定时任务(每天凌晨):
④ 每日榜数据归档 MySQL → 清空每日榜
查询接口:
- Top 100: ZREVRANGE rank:daily 0 99 WITHSCORES
- 个人排名: ZREVRANK rank:daily userId
- 个人积分: ZSCORE rank:daily userId
Redis 故障数据丢失
AOF everysec + 每天定时 BGSAVE 快照。更稳妥:核心积分变更同时写 MySQL 和 Redis,Redis 故障后用 MySQL 重建排行榜。
三个设计题的共同思路:需求澄清→数据量估算(BOTEC)→核心算法选型→存储方案→架构分层→边界问题处理。核心价值不在于方案本身,而在于从约束条件一步步推导出方案的过程。
核心要点回顾
CAP 定理 的工程实践结论:P(分区容忍性)必须选择------网络分区是物理现实而非小概率事件;剩下在 C(一致性)和 A(可用性)之间根据业务语义权衡。金融核心链路选 CP------宁可短暂不可用也不能数据不一致;非核心链路选 AP------最终一致即可。同一系统内不同服务可以有不同的 CAP 选择:支付系统中订单服务选 AP(继续接受订单返回"处理中")、账户服务选 CP(拒绝写入返回"系统繁忙")。PACELC 模型进一步细化:即使无网络分区,正常运行时仍存在延迟(L)与一致性(C)的取舍。
分布式事务 按一致性强度递进:2PC 的致命缺陷是同步阻塞锁资源加协调者单点故障,金融系统基本不使用;TCC(Try-Confirm-Cancel)将事务控制权上移到业务层------Try 预留资源校验、Confirm 确认执行、Cancel 取消释放,性能最高但侵入最大;MQ 最终一致性基于 RocketMQ 事务消息(half 消息→本地事务→commit/rollback→回查兜底),适用于非核心链路;Seata AT 模式侵入最小(@GlobalTransactional 注解),代价是隔离性较弱。核心交易链路用 TCC,非核心链路用 MQ 最终一致加 Seata AT。
Raft 共识拆解为三个子问题。Leader 选举:随机化超时时间(150-300ms)避免 Split Vote 活锁,Candidate 的日志必须不比投票者旧,每个 term 内每节点最多投一票。日志复制:AppendEntries RPC 通过 prevLogIndex 和 prevLogTerm 进行一致性检查------不匹配则 Leader 逐条回退直到找到一致点后覆盖写入。Raft 比 Paxos 易懂且功能等价,但 OceanBase 因起步早(Raft 论文 2013 年才发表)和 Multi-Paxos 允许日志不严格连续(适合分布式数据库的并发事务提交)而选择了 Paxos 系。
三大系统设计的共同思路是需求澄清→数据量估算(BOTEC)→核心算法选型→存储方案→架构分层→边界处理。短链系统:Snowflake 生成唯一 ID→Base62 转 7 位短码→Caffeine+Redis+MySQL 三级缓存→302 重定向。秒杀系统:CDN 静态化挡 99% 流量→网关令牌桶限流→MQ 削峰异步→Redis Lua 脚本原子扣库存(单线程模型免疫锁竞争)→异步写 MySQL→定时对账。排行榜系统:Redis ZSet 的 skiplist O(log N) 天然适合排名场景,积分相同时将 timestamp 编码到 score 小数部分实现"先达到排前面"。
能最终一致就别强一致------省下的不只是性能,还有开发成本和运维复杂度。收藏这张四方案决策树,下次架构评审直接翻出来对照。
下一篇 :《计算机网络基础:TCP三次握手为什么不是两次?TLS 1.3怎么做到1-RTT?》 系列合集 :掘金Java合集