从读侧回到写侧
上一篇把读路径的极端倾斜(热点 Key)收拾干净了,这一篇站到写侧:IM 系统消息表拆到 1024 张分片后,第一个撞墙的问题朴素得让人意外——一条消息的 ID 从哪来。单机时代答案是AUTO_INCREMENT,分片之后每个库各自自增必然撞号。别小看这个"发号"问题:它在大促零点每秒要吐出几十万个号,要求全局唯一(撞号=消息覆盖)、趋势递增(B+ 树追加写,页分裂可控)、不依赖单点(发号器自己不能成为新热点)、还不能被竞品猜出体量。这一篇把两条主流路线——Snowflake 与号段模式——都用虚拟时钟实现一遍,重点踩两个真事故高发区:时钟回拨和号段跳号。
先说清楚为什么不用另外两条路
多主自增步长错开(A 库发奇数、B 库发偶数)只在库数固定时成立:加一个从库要重排步长,且每个库的自增值仍是热点行(每次插入都更新同一行的计数器,innodb_autoinc_lock_mode调来调去也只是缓解)。UUID是唯一性无忧了,但 128 位随机串做主键意味着:InnoDB 聚簇索引彻底随机插入,页分裂率暴涨、缓存命中率跳水、每行还要多背 16 字节的索引成本——第 1 篇讲页结构时说过的账,这里全额兑现。工程实践里 UUID 只做业务侧幂等键,不进主键。
Snowflake:把 ID 拆成三段位域
Snowflake 的 64 位布局:41 位毫秒时间戳(自定义起点)+ 10 位机器号 + 12 位序列号。时间戳占大头,意味着单机每毫秒最多 4096 个号(序列号耗尽就自旋等下一毫秒);机器号 10 位意味着最多 1024 个发号节点,节点之间靠机器号隔离、互不通信——这是它吞吐高的原因,也是它把身家性命押在"每台机器时间单调向前"这个假设上的原因。真实部署里 NTP 校时、虚拟机热迁移、闰秒调整都可能在任何时刻把时钟往回拨,于是有了"回拨策略"这门学问。用虚拟时钟重放一段带 5 毫秒回拨的发号序列,对比宽容(冻结时钟借未来毫秒)与严格(直接拒发)两种策略:
# Snowflake: 41 位毫秒时间戳 + 10 位机器号 + 12 位序列号# 虚拟时钟: 一串毫秒增量, 内含一次 -5ms 回拨, 对比宽容/严格两种策略WORKER_BITS,SEQ_BITS=10,12MAX_SEQ=(1<<SEQ_BITS)-1WORKER_ID=835classSnowflake:def__init__(self,worker_id,tol_ms=5,strict=False):self.worker,self.tol,self.strict=worker_id,tol_ms,strict self.last_ms,self.seq=-1,0defnext_id(self,now_ms):ifnow_ms<self.last_ms:# 时钟回拨了back=self.last_ms-now_msifself.strictorback>self.tol:returnNone,"拒发: 回拨 %dms"%back# 报警+摘流/停写now_ms=self.last_ms# 冻结时钟, 向未来借毫秒ifnow_ms==self.last_ms:self.seq=(self.seq+1)&MAX_SEQifself.seq==0:returnNone,"等待: 同毫秒序列耗尽"# 真实实现自旋到下一毫秒else:self.seq=0self.last_ms=now_msreturn(now_ms<<(WORKER_BITS+SEQ_BITS))|(self.worker<<SEQ_BITS)|self.seq,"OK"defdecode(i):seq=i&MAX_SEQ worker=(i>>SEQ_BITS)&((1<<WORKER_BITS)-1)ts_ms=i>>(SEQ_BITS+WORKER_BITS)returnts_ms,worker,seq drifts=[0,0,0,1,1,2,3,-5,5,1,2,2]# 虚拟毫秒增量序列base=1767225600000# 固定起点(不读系统时钟)soft,hard=Snowflake(WORKER_ID),Snowflake(WORKER_ID,strict=True)t=base last_soft_id=Noneprint("机器号 %d (占 %d 位), 时间戳起点 %d"%(WORKER_ID,WORKER_BITS,base))fori,dinenumerate(drifts,1):t+=d sid,smsg=soft.next_id(t)hid,hmsg=hard.next_id(t)note_s=smsgifsidisNoneelse"seq=%d"%(sid&MAX_SEQ)note_h=hmsgifhidisNoneelse"seq=%d"%(hid&MAX_SEQ)print("请求%02d 时钟偏移%+5dms -> 宽容: %-14s | 严格: %s"%(i,t-base,note_s,note_h))ifsidisnotNone:last_soft_id=sid ts,wk,sq=decode(last_soft_id)print("解码最后一个宽容模式 ID: 时间戳=%d(+%dms) 机器=%d 序列=%d"%(ts,ts-base,wk,sq))运行输出:
机器号 835 (占 10 位), 时间戳起点 1767225600000 请求01 时钟偏移 +0ms -> 宽容: seq=0 | 严格: seq=0 请求02 时钟偏移 +0ms -> 宽容: seq=1 | 严格: seq=1 请求03 时钟偏移 +0ms -> 宽容: seq=2 | 严格: seq=2 请求04 时钟偏移 +1ms -> 宽容: seq=0 | 严格: seq=0 请求05 时钟偏移 +2ms -> 宽容: seq=0 | 严格: seq=0 请求06 时钟偏移 +4ms -> 宽容: seq=0 | 严格: seq=0 请求07 时钟偏移 +7ms -> 宽容: seq=0 | 严格: seq=0 请求08 时钟偏移 +2ms -> 宽容: seq=1 | 严格: 拒发: 回拨 5ms 请求09 时钟偏移 +7ms -> 宽容: seq=2 | 严格: seq=1 请求10 时钟偏移 +8ms -> 宽容: seq=0 | 严格: seq=0 请求11 时钟偏移 +10ms -> 宽容: seq=0 | 严格: seq=0 请求12 时钟偏移 +12ms -> 宽容: seq=0 | 严格: seq=0 解码最后一个宽容模式 ID: 时间戳=1767225600012(+12ms) 机器=835 序列=0读输出里两行就够了。请求08是全剧核心:虚拟时钟从 +7ms 被拨回 +2ms,宽容模式把发号时钟冻结在 +7ms、序列号照加——它"向未来借了 5 毫秒",只要真实时钟在借期内追回来(请求09 起 t≥+7),一个重号都不会有;严格模式当场拒发,宁可这笔业务失败。请求03则展示了同毫秒连发:靠 12 位序列号在同一时间戳里排出 0/1/2。两种策略都成立,但适用面不同:回拨在毫秒级、且时钟源会自己追回来的场景(NTP 渐进校正),冻结策略保可用性;回拨达秒级以上(时区误配、手动改表),冻结等于把未来几分钟的号段预支光,必须停写摘流——5ms 的容差就是这道分水岭,超过它,宽容模式自己会转成拒发。生产上还要补一刀:机器号必须由注册中心(ZooKeeper/etcd)分配并带租约,两台机器拿到同一个 worker id 时,一切时钟策略都是空谈。
号段模式:把 DB 当批发商
另一条路线彻底绕开时钟:发号服务一次从数据库批领一段号(UPDATE id_alloc SET max_id=max_id+1000然后返回区间),内存里慢慢批发。它对 DB 的压力从"每个号一次交互"降到"每 1000 号一次交互",还天然支持预取——当前段用到水位线就让后台线程把下一段备好,发号路径上永远没有同步 RPC。模拟 2500 次发号看 DB 交互次数与重启代价:
# 号段模式: 一次从 DB 申请 1000 个号, 用满 90% 时预取下一段(此处同步模拟)STEP,PREFETCH_RATIO=1000,0.9classDbSim:def__init__(self):self.max_id,self.calls=0,0defalloc(self):self.calls+=1lo=self.max_id+1self.max_id+=STEPreturn[lo,self.max_id,0]# [当前指针, 上界, 已用量]db=DbSim()cur=next_seg=Noneissued=0for_inrange(2500):ifcurisNone:cur=next_segifnext_segelsedb.alloc()next_seg=Noneprint("[%4d] 启用号段 [%4d, %4d] (DB 累计交互 %d 次)"%(issued+1,cur[0],cur[1],db.calls))issued+=1cur[0]+=1cur[2]+=1ifnext_segisNoneandcur[2]>=STEP*PREFETCH_RATIO:next_seg=db.alloc()print("[%4d] 当前段已用 %d/%d -> 预取下一段 [%4d, %4d]"%(issued,cur[2],STEP,next_seg[0],next_seg[1]))ifcur[0]>cur[1]:cur=Noneprint("共发号 %d 个, DB 交互 %d 次 (逐行自增需要 %d 次)"%(issued,db.calls,issued))left=0ifcurisNoneelsecur[1]-cur[0]+1print("若此刻进程重启: 手上号段还剩 %d 个号作废 -> 号段模式的跳号来源"%left)运行输出:
[ 1] 启用号段 [ 1, 1000] (DB 累计交互 1 次) [ 900] 当前段已用 900/1000 -> 预取下一段 [1001, 2000] [1001] 启用号段 [1001, 2000] (DB 累计交互 2 次) [1900] 当前段已用 900/1000 -> 预取下一段 [2001, 3000] [2001] 启用号段 [2001, 3000] (DB 累计交互 3 次) 共发号 2500 个, DB 交互 3 次 (逐行自增需要 2500 次) 若此刻进程重启: 手上号段还剩 500 个号作废 -> 号段模式的跳号来源2500 个号只碰了 3 次数据库,且每次换段都无缝衔接(预取在第 900 号就完成)——这就是"批发"的杠杆。但最后一行同样诚实:每次部署重启,手上没用完的号段整段作废。发号服务一天滚动发布两轮、每轮 20 个实例,就是 4 万个空洞。ID 只要求唯一不要求连续的业务无所谓;但若有"用订单号差值估单日单量"的外部接口,跳号会把商业数据泄露变成商业数据撒谎。对策分两层:号段内洗牌(段内乱序发号,让外人无法从相邻 ID 差推速率)或将步长缩到 100 以内换重启损失上限;对可用性要求更高的,把"单 DB 行"升级为"多机各自领段 + 段前缀区分",号段服务本身无状态化。
选型与落地清单
- 消息/时间强相关、需要 ID 可反解时间(对账、排序、分页游标):Snowflake;worker id 走注册中心租约,回拨容差 5ms 左右,超阈值拒发+告警
- 订单号要短、要发给外部、对 DB 压力敏感:号段模式;步长按"重启可接受的空洞数"倒推,段内乱序发号防推断
- 两条路线都可以再加一层"号段中间件"混合使用:Snowflake 的位段里塞机器号换成号段服务器编号,兼得趋势递增与可回收的节点管理
- 无论哪种:ID 一旦生成就不要再给业务赋予"数值语义"(用 ID 比大小排时间在新旧系统交替时会失效)
写侧的发号问题解决了,但读侧和写侧的交界处还压着一个老问题:数据先写库、缓存在什么时候删?为什么"先删缓存再写库"和"先写库再删缓存"都不对,要"延迟双删"?以及当这些招都失效时,如何靠 Binlog 把不一致的窟窿兜住?下一篇《高并发流量治理实战(7):缓存一致性工程:延迟双删与 Binlog 对账的实现》正面清算这笔账。
参考来源
- Wikipedia: Snowflake ID: https://en.wikipedia.org/wiki/Snowflake_ID
- Twitter/snowflake 设计文档(GitHub 归档仓库): https://github.com/twitter/snowflake
- RFC 4122: Universally Unique Identifiers (UUID): https://datatracker.ietf.org/doc/html/rfc4122
- MySQL 8.0 Reference Manual: InnoDB Auto-Increment Handling: https://dev.mysql.com/doc/refman/8.0/en/innodb-auto-increment-handling.html
- Wikipedia: Universally unique identifier: https://en.wikipedia.org/wiki/Universally_unique_identifier
本系列已结集为免费专栏:高并发流量治理实战:从限流到全链路压测;进阶推荐付费专栏:提示词工程实战:从入门到生产级 Prompt 设计(限时 ¥19.9,首篇免费试读)