在大型分布式系统与微服务架构中,全局唯一 ID 发号器是几乎所有核心业务(订单、支付、履约、物流)的生命线。一旦发号器发生单点故障或者产生重复 ID,下游的分库分表路由就会错乱,甚至引发灾难性的账单覆盖事故。
业界常见的发号方案主要有三类:UUID、雪花算法(Snowflake)以及基于数据库的号段模式(Segment Pattern)。雪花算法虽然纯内存计算、吞吐极高,但它高度依赖机器本地时钟,一旦遭遇 NTP 时钟回拨或者容器漂移,维护成本陡增。相比之下,以美团 Leaf 为代表的数据库号段模式,因其天然递增、可读性好且架构直观,成为了很多中大厂的核心首选。
然而,很多同学在面试中聊到号段模式时,往往只背得出“每次从 DB 批量申请一批 ID 缓存到内存,用完了再去查一次库”。但终面官真正看重的,是系统在面对数据库主从切换延迟、网络分区、以及发号服务节点突然宕机等极端异常时的防御弹性。
今天我们深入剖析数据库号段模式在极端宕机场景下的安全性防御机制,并给出双 Buffer 异步预加载的生产级代码实现。
核心架构与乐观锁版本控制
号段模式的本质是将高频的单次数据库写入,降维为低频的批量租约申请。
在数据库中,我们通常只需维护一张极度轻量的发号配置表:
CREATE TABLE `tiny_id_alloc` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `biz_tag` varchar(64) NOT NULL DEFAULT '' COMMENT '业务标识符,如 order_id, pay_id', `max_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '当前已分配的最大 ID 上限', `step` int(11) NOT NULL DEFAULT '1000' COMMENT '单次发号租约步长', `version` bigint(20) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_tag` (`biz_tag`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号段模式发号元数据表';当某个发号服务节点需要拉取新号段时,必须通过带版本号的乐观锁更新语句来防止并发覆盖:
UPDATE tiny_id_alloc SET max_id = max_id + step, version = version + 1 WHERE biz_tag = 'order_id' AND version = #{currentVersion};如果受影响行数为 1,代表成功拿到了范围为[max_id - step + 1, max_id]的号段区间;如果为 0,代表其他节点并发抢占了号段,当前节点必须重新读取最新版本并重试。
极端场景一:服务节点突然宕机——“ID 空洞”与业务契约
当一个发号服务实例刚从数据库加载了[10001, 20000]共 1 万个 ID 到内存中,刚发到第 10050 号,物理机突发硬件掉电宕机,会发生什么?
很多人第一反应是“能不能把内存里的当前发号进度实时刷盘”?
答案是绝对不能。如果每次发号都要把内存指针同步写磁盘,号段模式相比直接写数据库将毫无性能优势。
正确应对姿势:接受 ID 空洞(Gaps),明确单调递增契约
发号节点重启后,会从数据库重新拉取max_id,此时它拉到的新号段将从20001开始。这意味着[10051, 20000]这 9950 个 ID 将永远不会被发放,系统在序列上留下了永久的**“空洞(Gap)”**。
在架构设计中,必须向业务方明确声明契约:
- 保障属性:全局绝对唯一、整体趋势递增(Trend-Increasing);
- 非保障属性:绝对连续递增(Strictly Monotonic Consecutive)。
除了特殊的税务发票号要求绝对连续外,绝大多数电商订单号、交易流水号完全允许空洞的存在。为了防止单次宕机造成的空洞过大,号段步长(step)通常通过动态流量估算,维持在“能支撑该业务 10~15 分钟消耗”的水平,既降低了 DB 访问频度,又把宕机损失控制在可接受范围内。
极端场景二:数据库宕机——双 Buffer 异步预加载防卡顿
如果发号服务正在使用第一个号段,当号段消耗殆尽时才同步去查数据库拉取下一个号段,一旦此时数据库发生主从切换或网络抖动,调用方就会发生长达数秒的接口线程阻塞。
工业级解法是双 Buffer(双缓冲队列)异步预加载:
内存中同时常驻两个 Buffer。当 Buffer 1 的消耗进度达到 20% 阈值(剩余 80%)时,后台守护线程立即被唤醒,异步访问数据库拉取 Buffer 2;当 Buffer 1 彻底用完时,内存指针秒级原子切换到 Buffer 2,完全不需要等待网络 IO。
以下是使用 Java 24 编写的双 Buffer 核心无锁实现:
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; public class SegmentBufferManager { public static class Segment { private final AtomicLong currentId = new AtomicLong(); private volatile long maxId; private volatile int step; private volatile boolean isReady = false; public void init(long startId, int step) { this.currentId.set(startId); this.maxId = startId + step - 1; this.step = step; this.isReady = true; } public long getNextId() { long id = currentId.getAndIncrement(); return id <= maxId ? id : -1; // -1 代表当前号段已耗尽 } public long getRemaining() { return Math.max(0, maxId - currentId.get() + 1); } } private final Segment[] buffers = new Segment[]{new Segment(), new Segment()}; private volatile int currentBufferIndex = 0; private final AtomicBoolean isAllocatingNextSegment = new AtomicBoolean(false); public long getId() { while (true) { Segment current = buffers[currentBufferIndex]; // 阈值检查:剩余不足 20% 时触发异步加载下一个 Buffer if (current.isReady && current.getRemaining() < current.step * 0.2) { triggerAsyncLoadNextBuffer(); } long id = current.getNextId(); if (id != -1) { return id; // 成功获取 ID } // 当前号段耗尽,尝试切换到下一个 Buffer switchBuffer(); } } private synchronized void switchBuffer() { int nextIndex = (currentBufferIndex + 1) % 2; Segment nextSegment = buffers[nextIndex]; if (!nextSegment.isReady) { // 下一个 Buffer 尚未就绪(DB 可能发生故障挂起) throw new IllegalStateException("发号器双 Buffer 枯竭,数据库服务可能异常!"); } // 原子切换当前活动 Buffer,并将旧 Buffer 标记为失效 buffers[currentBufferIndex].isReady = false; currentBufferIndex = nextIndex; } private void triggerAsyncLoadNextBuffer() { int nextIndex = (currentBufferIndex + 1) % 2; Segment nextSegment = buffers[nextIndex]; if (!nextSegment.isReady && isAllocatingNextSegment.compareAndSet(false, true)) { // 使用轻量虚拟线程异步拉取,不阻塞主发号流 Thread.ofVirtual().start(() -> { try { // 模拟从数据库乐观锁拉取最新号段 long startId = fetchNextRangeFromDatabase(); nextSegment.init(startId, 5000); } finally { isAllocatingNextSegment.set(false); } }); } } private long fetchNextRangeFromDatabase() { // 生产环境中通过 JdbcTemplate 执行乐观锁更新并返回起始 ID return System.currentTimeMillis(); } }极端场景三:DB 主从切换脏写防御
在 MySQL MHA 或高可用容器集群中,主库宕机发生主从切换时,新主库可能尚未同步旧主库最新的max_id。如果发号器直接连上新主库,可能会申请到比旧主库曾经发出去的 ID 还要小的旧号段,导致严重的 ID 倒流与主键冲突。
针对这种灾难,发号服务有两道终极防线:
- 客户端发号单调性防御:发号节点内存中维护一个
globalMaxDispatchedId。任何从数据库拉取下来的新号段,若其起始值小于当前本地已发放的最大 ID,发号器立即拒绝启动并触发 P0 级严重告警; - 多机房 Paxos 选主对齐:在顶级金融业务中,发号元数据不直接存放在普通异步复制 MySQL,而是采用内置 Raft/Paxos 协议的一致性 KV 存储(如 etcd 或 TiDB),从存储层杜绝主从切换导致的数据脑裂。