Skip to content

分片、分布式事务与消息 ​

当单机容量、吞吐或可用性不够时,常把数据拆到多个节点,并用消息连接不同服务。扩展能力的同时,也会失去单机事务和单一调用栈,需要显式设计路由、幂等、重试、顺序和补偿。

数据分片 ​

分片(Sharding)把不同 key 的数据放到不同节点,每个分片只负责一部分数据。分片键决定负载是否均匀,也决定查询是否需要跨分片。

策略优点缺点
哈希分片 hash(key) % N分布通常均匀、点查简单节点数变化时大量 key 重映射,范围查询差
范围分片范围扫描和排序友好容易出现时间或地域热点,需要动态切分
目录分片路由灵活,可单独迁移 key / 租户依赖高可用元数据服务,路由多一次查询
一致性哈希节点变化时只迁移部分 key实现和均衡更复杂,仍不能自动解决热 key

一致性哈希与虚拟节点 ​

一致性哈希把节点和 key 映射到同一哈希环,key 通常归属于顺时针遇到的第一个节点。增加或删除物理节点时,只影响相邻区间,而不是重新映射全部 key。

单个物理节点在环上放置多个虚拟节点,可改善数据均衡,并通过给高配置机器分配更多虚拟节点实现权重。但它不能解决一个超级热 key:该 key 仍由单个主分片处理,需要复制读、业务拆分、局部聚合或单独限流。

如何选择分片键 ​

好的分片键通常满足:

  • 基数足够高,避免大量请求集中到少数值;
  • 主要查询能够直接定位分片,避免每次广播;
  • 同一事务或强顺序实体尽量落在同一分片;
  • 不使用持续递增且直接按范围分片的值制造尾部热点;
  • 考虑未来迁移、租户隔离和热点拆分。

例如按 room_id 分片能让同一房间消息保持局部顺序,但一个超大房间会成为热点。按 player_id 分片利于玩家状态,却可能让房间广播跨多个分片。不存在对所有访问模式都完美的分片键。

在线迁移与扩容 ​

在线迁移不能只“复制完数据后修改配置”,因为复制期间仍有新写入。常见流程是:

  1. 记录迁移起点并复制历史快照;
  2. 通过变更日志、双写或增量同步追赶新写;
  3. 校验源和目标数据;
  4. 原子更新带版本的路由配置;
  5. 用 epoch / fencing 拒绝写入旧分片;
  6. 观察稳定后清理旧副本。

双写本身可能部分成功,不能天然保证一致;更可靠的方式是让一个权威写入点落本地事务日志,再异步复制。

分布式 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 串行化更新或短期加锁;
  • 延迟双删可缩短某些竞态窗口,但不是严格一致性证明。

需要线性一致读时,通常不能仅依赖普通旁路缓存;可绕过缓存、读权威主节点,或把缓存纳入带版本的强一致协议。

常见错误 ​

  1. 认为一致性哈希能解决热 key;它只减少节点变化时的数据迁移;
  2. 选择分片键时只看均匀分布,不考虑主要查询和事务边界;
  3. 双写两个系统后忽略一边成功、一边失败;
  4. 把 Saga 补偿当作数据库回滚,忽略中间状态已经被外部观察;
  5. 生产者不重复就认为消费者不会重复;确认丢失也会造成重投;
  6. 宣称端到端 exactly-once,却没有把外部副作用纳入事务或幂等;
  7. 以为同一 Topic 天然全局有序;通常只保证分区内顺序;
  8. 无限立即重试,放大依赖故障并造成重试风暴;
  9. 缓存更新和数据库提交分别执行,却没有版本、失效或补偿机制。

面试中的简洁回答 ​

分片首先要根据访问模式选择 shard key,在负载均匀、查询定位和事务局部性之间取舍;一致性哈希减少扩缩容迁移,但不解决单个热 key。跨服务事务优先避免,确实需要时可根据一致性要求选择 2PC、TCC、Saga 或 transactional outbox。消息系统常采用 at-least-once,因此消费者必须幂等;同一实体要有序,就按实体 ID 路由到同一分区,并用 sequence 或状态机处理重试和迟到消息。

关联笔记 ​

使用 Markdown 与 VitePress 构建