Appearance
分片、分布式事务与消息
当单机容量、吞吐或可用性不够时,常把数据拆到多个节点,并用消息连接不同服务。扩展能力的同时,也会失去单机事务和单一调用栈,需要显式设计路由、幂等、重试、顺序和补偿。
数据分片
分片(Sharding)把不同 key 的数据放到不同节点,每个分片只负责一部分数据。分片键决定负载是否均匀,也决定查询是否需要跨分片。
| 策略 | 优点 | 缺点 |
|---|---|---|
哈希分片 hash(key) % N | 分布通常均匀、点查简单 | 节点数变化时大量 key 重映射,范围查询差 |
| 范围分片 | 范围扫描和排序友好 | 容易出现时间或地域热点,需要动态切分 |
| 目录分片 | 路由灵活,可单独迁移 key / 租户 | 依赖高可用元数据服务,路由多一次查询 |
| 一致性哈希 | 节点变化时只迁移部分 key | 实现和均衡更复杂,仍不能自动解决热 key |
一致性哈希与虚拟节点
一致性哈希把节点和 key 映射到同一哈希环,key 通常归属于顺时针遇到的第一个节点。增加或删除物理节点时,只影响相邻区间,而不是重新映射全部 key。
单个物理节点在环上放置多个虚拟节点,可改善数据均衡,并通过给高配置机器分配更多虚拟节点实现权重。但它不能解决一个超级热 key:该 key 仍由单个主分片处理,需要复制读、业务拆分、局部聚合或单独限流。
如何选择分片键
好的分片键通常满足:
- 基数足够高,避免大量请求集中到少数值;
- 主要查询能够直接定位分片,避免每次广播;
- 同一事务或强顺序实体尽量落在同一分片;
- 不使用持续递增且直接按范围分片的值制造尾部热点;
- 考虑未来迁移、租户隔离和热点拆分。
例如按 room_id 分片能让同一房间消息保持局部顺序,但一个超大房间会成为热点。按 player_id 分片利于玩家状态,却可能让房间广播跨多个分片。不存在对所有访问模式都完美的分片键。
在线迁移与扩容
在线迁移不能只“复制完数据后修改配置”,因为复制期间仍有新写入。常见流程是:
- 记录迁移起点并复制历史快照;
- 通过变更日志、双写或增量同步追赶新写;
- 校验源和目标数据;
- 原子更新带版本的路由配置;
- 用 epoch / fencing 拒绝写入旧分片;
- 观察稳定后清理旧副本。
双写本身可能部分成功,不能天然保证一致;更可靠的方式是让一个权威写入点落本地事务日志,再异步复制。
分布式 ID
分布式 ID 需要在唯一性、有序性、长度、生成延迟和基础设施依赖之间取舍:
| 方案 | 特点 | 注意点 |
|---|---|---|
| UUID | 本地生成、基本无协调 | 体积较大、随机写索引局部性差 |
| 数据库号段 | 一次申请一段 ID,本地递增 | 依赖号段服务;进程重启可能跳号但不应重复 |
| Snowflake 类 | 时间戳 + 节点号 + 序列号,趋势递增 | 时钟回拨、节点号冲突、单毫秒序列耗尽 |
| 共识序列 | 强顺序、语义清晰 | 每次协调成本高,不适合高吞吐业务 ID |
“全局唯一”和“严格全局递增”不是同一要求。多数业务只需要唯一或趋势递增;若强求无间隙严格递增,会把 ID 服务变成集中式串行瓶颈。
分布式事务为什么困难
单库事务由一个事务管理器控制日志、锁和提交;跨数据库或服务后,参与者可能独立失败,协调者无法通过一次本地提交原子完成所有操作。首先应判断是否真的需要跨服务强事务:把强相关数据放到同一服务 / 分片,通常比引入复杂协议更可靠。
2PC(两阶段提交)
text
阶段 1 Prepare:协调者询问所有参与者是否能提交,参与者持久化准备状态并锁定资源
阶段 2 Commit/Abort:全部同意则提交,否则回滚2PC 可以协调原子提交,但代价包括:
- 参与者进入 prepared 后可能持有锁,协调者故障时等待最终决定;
- 一次事务需要多轮网络和多次持久化,延迟较高;
- 网络分区时通常降低可用性;
- 协调者恢复、参与者恢复和启发式处理都依赖持久化日志。
2PC 不是“没有故障时的两次 RPC”这么简单,也不自动解决所有业务隔离问题。适合参与者和基础设施明确支持、且确实需要原子提交的场景。
TCC
TCC 把业务操作拆成:
- Try:预留资源;
- Confirm:确认消费;
- Cancel:释放预留。
它把资源锁定和补偿暴露给业务,吞吐可比长数据库事务更灵活,但每个阶段必须幂等,还要处理空回滚、悬挂(Cancel 先于 Try 到达)和重复调用。
Saga
长事务拆成一串本地事务,每一步成功后触发下一步;后续失败则逆序执行补偿操作。Saga 提供最终一致性,不是隔离的 ACID 回滚:其他请求可能看到中间状态,补偿也不一定能恢复现实世界操作,例如已发送通知或已产生外部费用。
Transactional Outbox
本地事务同时写业务表和 outbox 事件表:
text
BEGIN
update business_data
insert outbox_event
COMMIT后台 relay 或 CDC 将 outbox 发布到消息系统。这样避免“数据库提交成功但消息未发送”和“消息已发送但数据库回滚”的原子性缺口。发布过程仍可能重复,因此消费者要使用 event ID 去重;也可维护 inbox / processed-events 表,在本地事务中同时记录消费结果。
幂等设计
幂等表示同一逻辑请求执行一次和执行多次的最终效果相同。网络超时后,调用方无法确定请求是否执行,因此所有可重试写路径都应考虑幂等。
常见方法:
- 幂等键 / request ID:服务端持久化请求状态和结果,重复请求直接返回原结果;
- 唯一约束:用数据库唯一键阻止重复创建;
- 条件更新 / CAS:只允许状态从预期版本迁移,例如
WHERE version = old_version; - 业务状态机:只允许合法的单向状态转换;
- 消息去重表:消费结果与 event ID 在同一本地事务中提交;
- 天然幂等操作:设置为确定值通常比“累加一次”更容易重试。
幂等记录需要定义作用域和过期时间。过早删除去重记录,迟到重试仍可能重复执行;永久保存又会无限增长。
消息投递语义
| 语义 | 含义 | 典型代价 |
|---|---|---|
| At-most-once | 最多一次,失败时可能丢失 | 不重试或先确认后处理 |
| At-least-once | 至少一次,不轻易丢失但可能重复 | 重试 + 消费者幂等 |
| Exactly-once | 在明确边界内效果恰好一次 | 事务、去重、状态绑定,成本高 |
“消息系统支持 exactly-once”通常只覆盖特定生产、消费和存储边界。若消费者还调用外部支付、数据库或第三方接口,端到端恰好一次仍需要这些资源共同参与事务,或依靠幂等和补偿实现效果上的恰好一次。
消息队列的日志、分区与消费位点
常见消息队列把消息追加到分区日志中;消息在分区内拥有单调递增的 offset,消费者通过消费位点记录“已经处理到哪里”。这和“消息已经被网络送达”不是同一件事:
| 概念 | 含义 | 设计注意 |
|---|---|---|
| 分区(partition) | 独立追加和读取的日志,通常是并行度与顺序边界 | 同一业务实体用同一 key 路由,才有局部顺序 |
| 消费者组 | 组内一个分区通常只分配给一个活跃消费者 | 扩容超过分区数不会继续提升该组消费并行度 |
| offset | 分区内的位置,不是全局消息 ID | 重平衡或重启后按提交位点继续,可能重复或跳过 |
| ack / 位点提交 | 表示消息处理进度(具体语义依赖系统) | 应在业务结果可靠落库后提交,避免先提交后崩溃造成丢失 |
| retention / compaction | 按时间或大小保留日志,或按 key 保留最新值 | 位点过旧时可能无法回放,需要快照或重置策略 |
典型的 at-least-once 消费流程是:拉取消息 → 校验版本/幂等键 → 执行本地事务并记录 event ID → 事务成功后提交位点。进程在事务提交和位点提交之间崩溃时,消息会再次投递,所以消费者必须幂等。若先提交位点再执行业务,崩溃会造成消息丢失。
消息队列的积压量是背压信号,应同时监控每个分区的 oldest-message age、消费速率、重试率和死信数。盲目增加消费者只有在分区、下游容量和 CPU 都允许时才有效;否则会把积压转移成数据库连接耗尽或下游限流。
确认、重试和死信
消费者应在业务结果可靠持久化后再确认消息。处理失败时使用带上限的指数退避和随机抖动,避免所有消费者同时重试形成惊群。超过次数后进入死信队列并告警,但死信不是最终解决方案,还要提供查看、修复和安全重放能力。
不可恢复错误和暂时错误应分开处理。参数非法不应无限重试;依赖服务短暂超时可以退避重试。重放消息前要确认消费者逻辑仍幂等。
消息顺序
分布式消息系统通常只能保证单分区或单队列内的顺序,不能低成本保证全局顺序。若同一玩家、房间或订单必须串行处理,应使用实体 ID 作为分区键:
text
partition = hash(entity_id) % partition_count同一实体进入同一分区并由一个活跃消费者处理;不同实体可以并行。仍要考虑:
- 失败重试是否把旧消息送入延迟队列,从而让后续消息先执行;
- 分区扩容后哈希映射变化,迁移期是否出现两个消费者;
- 生产者重试是否导致重复或序号跳跃;
- 业务状态机能否用 sequence / version 拒绝旧消息。
服务调用的超时与容错
同步 RPC 必须有 deadline,并把剩余时间向下游传播,避免上游已经超时、下游仍继续消耗资源。重试只适用于幂等操作或有幂等键的写操作,并使用指数退避和随机抖动。
- 熔断:依赖持续失败时暂时快速拒绝,避免每个请求都等待超时;
- 隔舱(Bulkhead):为不同依赖设置独立线程池、连接池或并发额度,防止一个依赖拖垮全部请求;
- 限流:在入口控制负载,不让系统进入排队时间无限增长的状态;
- 负载均衡:轮询适合节点相近,least-request 更关注当前负载,一致性哈希适合会话 / 缓存亲和;
- 健康检查:注册中心心跳只能表示近期可达,不能证明节点当前一定能完成业务请求。
重试会放大负载:一条调用链每层都重试,最坏请求数按层数乘法增长。通常由最了解幂等性和总 deadline 的一层负责重试,并设置重试预算。
缓存与数据库一致性
常见 Cache-Aside 流程:读缓存未命中后查数据库并回填;写入时先提交数据库,再删除缓存。删除而不是直接更新缓存,可以避免业务在多个地方维护复杂缓存结构,但仍存在并发窗口。
典型竞态:旧读请求在数据库更新前读到旧值,写请求更新数据库并删除缓存后,旧读请求又把旧值回填。常见缓解方式包括:
- 设置合理 TTL,让错误缓存最终过期;
- 回填时携带版本,只允许新版本覆盖旧版本;
- 用 CDC / binlog 统一驱动失效;
- 对同一 key 串行化更新或短期加锁;
- 延迟双删可缩短某些竞态窗口,但不是严格一致性证明。
需要线性一致读时,通常不能仅依赖普通旁路缓存;可绕过缓存、读权威主节点,或把缓存纳入带版本的强一致协议。
常见错误
- 认为一致性哈希能解决热 key;它只减少节点变化时的数据迁移;
- 选择分片键时只看均匀分布,不考虑主要查询和事务边界;
- 双写两个系统后忽略一边成功、一边失败;
- 把 Saga 补偿当作数据库回滚,忽略中间状态已经被外部观察;
- 生产者不重复就认为消费者不会重复;确认丢失也会造成重投;
- 宣称端到端 exactly-once,却没有把外部副作用纳入事务或幂等;
- 以为同一 Topic 天然全局有序;通常只保证分区内顺序;
- 无限立即重试,放大依赖故障并造成重试风暴;
- 缓存更新和数据库提交分别执行,却没有版本、失效或补偿机制。
面试中的简洁回答
分片首先要根据访问模式选择 shard key,在负载均匀、查询定位和事务局部性之间取舍;一致性哈希减少扩缩容迁移,但不解决单个热 key。跨服务事务优先避免,确实需要时可根据一致性要求选择 2PC、TCC、Saga 或 transactional outbox。消息系统常采用 at-least-once,因此消费者必须幂等;同一实体要有序,就按实体 ID 路由到同一分区,并用 sequence 或状态机处理重试和迟到消息。