ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高并发流量治理实战(6):分布式 ID 生成:Snowflake 时钟回拨与号段模式

高并发流量治理实战(6):分布式 ID 生成:Snowflake 时钟回拨与号段模式 从读侧回到写侧上一篇把读路径的极端倾斜热点 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_BITS10,12MAX_SEQ(1SEQ_BITS)-1WORKER_ID835classSnowflake:def__init__(self,worker_id,tol_ms5,strictFalse):self.worker,self.tol,self.strictworker_id,tol_ms,strict self.last_ms,self.seq-1,0defnext_id(self,now_ms):ifnow_msself.last_ms:# 时钟回拨了backself.last_ms-now_msifself.strictorbackself.tol:returnNone,拒发: 回拨 %dms%back# 报警摘流/停写now_msself.last_ms# 冻结时钟, 向未来借毫秒ifnow_msself.last_ms:self.seq(self.seq1)MAX_SEQifself.seq0:returnNone,等待: 同毫秒序列耗尽# 真实实现自旋到下一毫秒else:self.seq0self.last_msnow_msreturn(now_ms(WORKER_BITSSEQ_BITS))|(self.workerSEQ_BITS)|self.seq,OKdefdecode(i):seqiMAX_SEQ worker(iSEQ_BITS)((1WORKER_BITS)-1)ts_msi(SEQ_BITSWORKER_BITS)returnts_ms,worker,seq drifts[0,0,0,1,1,2,3,-5,5,1,2,2]# 虚拟毫秒增量序列base1767225600000# 固定起点(不读系统时钟)soft,hardSnowflake(WORKER_ID),Snowflake(WORKER_ID,strictTrue)tbase last_soft_idNoneprint(机器号 %d (占 %d 位), 时间戳起点 %d%(WORKER_ID,WORKER_BITS,base))fori,dinenumerate(drifts,1):td sid,smsgsoft.next_id(t)hid,hmsghard.next_id(t)note_ssmsgifsidisNoneelseseq%d%(sidMAX_SEQ)note_hhmsgifhidisNoneelseseq%d%(hidMAX_SEQ)print(请求%02d 时钟偏移%5dms - 宽容: %-14s | 严格: %s%(i,t-base,note_s,note_h))ifsidisnotNone:last_soft_idsid ts,wk,sqdecode(last_soft_id)print(解码最后一个宽容模式 ID: 时间戳%d(%dms) 机器%d 序列%d%(ts,ts-base,wk,sq))运行输出机器号 835 (占 10 位), 时间戳起点 1767225600000 请求01 时钟偏移 0ms - 宽容: seq0 | 严格: seq0 请求02 时钟偏移 0ms - 宽容: seq1 | 严格: seq1 请求03 时钟偏移 0ms - 宽容: seq2 | 严格: seq2 请求04 时钟偏移 1ms - 宽容: seq0 | 严格: seq0 请求05 时钟偏移 2ms - 宽容: seq0 | 严格: seq0 请求06 时钟偏移 4ms - 宽容: seq0 | 严格: seq0 请求07 时钟偏移 7ms - 宽容: seq0 | 严格: seq0 请求08 时钟偏移 2ms - 宽容: seq1 | 严格: 拒发: 回拨 5ms 请求09 时钟偏移 7ms - 宽容: seq2 | 严格: seq1 请求10 时钟偏移 8ms - 宽容: seq0 | 严格: seq0 请求11 时钟偏移 10ms - 宽容: seq0 | 严格: seq0 请求12 时钟偏移 12ms - 宽容: seq0 | 严格: seq0 解码最后一个宽容模式 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_idmax_id1000然后返回区间内存里慢慢批发。它对 DB 的压力从每个号一次交互降到每 1000 号一次交互还天然支持预取——当前段用到水位线就让后台线程把下一段备好发号路径上永远没有同步 RPC。模拟 2500 次发号看 DB 交互次数与重启代价# 号段模式: 一次从 DB 申请 1000 个号, 用满 90% 时预取下一段(此处同步模拟)STEP,PREFETCH_RATIO1000,0.9classDbSim:def__init__(self):self.max_id,self.calls0,0defalloc(self):self.calls1loself.max_id1self.max_idSTEPreturn[lo,self.max_id,0]# [当前指针, 上界, 已用量]dbDbSim()curnext_segNoneissued0for_inrange(2500):ifcurisNone:curnext_segifnext_segelsedb.alloc()next_segNoneprint([%4d] 启用号段 [%4d, %4d] (DB 累计交互 %d 次)%(issued1,cur[0],cur[1],db.calls))issued1cur[0]1cur[2]1ifnext_segisNoneandcur[2]STEP*PREFETCH_RATIO:next_segdb.alloc()print([%4d] 当前段已用 %d/%d - 预取下一段 [%4d, %4d]%(issued,cur[2],STEP,next_seg[0],next_seg[1]))ifcur[0]cur[1]:curNoneprint(共发号 %d 个, DB 交互 %d 次 (逐行自增需要 %d 次)%(issued,db.calls,issued))left0ifcurisNoneelsecur[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 可反解时间对账、排序、分页游标Snowflakeworker id 走注册中心租约回拨容差 5ms 左右超阈值拒发告警订单号要短、要发给外部、对 DB 压力敏感号段模式步长按重启可接受的空洞数倒推段内乱序发号防推断两条路线都可以再加一层号段中间件混合使用Snowflake 的位段里塞机器号换成号段服务器编号兼得趋势递增与可回收的节点管理无论哪种ID 一旦生成就不要再给业务赋予数值语义用 ID 比大小排时间在新旧系统交替时会失效写侧的发号问题解决了但读侧和写侧的交界处还压着一个老问题数据先写库、缓存在什么时候删为什么先删缓存再写库和先写库再删缓存都不对要延迟双删以及当这些招都失效时如何靠 Binlog 把不一致的窟窿兜住下一篇《高并发流量治理实战7缓存一致性工程延迟双删与 Binlog 对账的实现》正面清算这笔账。参考来源Wikipedia: Snowflake ID: https://en.wikipedia.org/wiki/Snowflake_IDTwitter/snowflake 设计文档(GitHub 归档仓库): https://github.com/twitter/snowflakeRFC 4122: Universally Unique Identifiers (UUID): https://datatracker.ietf.org/doc/html/rfc4122MySQL 8.0 Reference Manual: InnoDB Auto-Increment Handling: https://dev.mysql.com/doc/refman/8.0/en/innodb-auto-increment-handling.htmlWikipedia: Universally unique identifier: https://en.wikipedia.org/wiki/Universally_unique_identifier本系列已结集为免费专栏高并发流量治理实战从限流到全链路压测进阶推荐付费专栏提示词工程实战从入门到生产级 Prompt 设计限时 ¥19.9首篇免费试读
返回列表