ARTICLE DETAIL

资讯详情

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

号段模式分布式ID生成器:双缓冲实现与高并发踩坑实践

号段模式分布式ID生成器:双缓冲实现与高并发踩坑实践 号段模式这四个字最近在分布式ID选型里出现的频率越来越高。我在订单系统和短链接服务里都用了这套方案核心代码其实不复杂但网上的资料大多停在概念图层面真正能落地的实现细节——并发抢号段的坑、双缓冲的触发时机、步长怎么算——很少讲透。这篇文章直接把号段模式核心代码实现拆开写从表结构、取号原语到单缓冲和双缓冲的演进再把我在线上踩过的坑一并列出来给正在做ID改造或者被数据库自增卡脖子的团队一个能直接抄作业的参考。1. 号段模式的思路拆解为什么不用自增ID和雪花算法1.1 数据库自增ID的瓶颈在哪里早期业务规模不大时数据库自增ID是最自然的选择。插入一条数据数据库返回一个自增值直接当主键用成本几乎为零。但等到并发量上来这个方案会暴露几个难以忽视的问题。第一是每次取ID都打数据库。无论你用LAST_INSERT_ID()还是先SELECT再INSERT每生成一个ID就产生一次数据库交互。假设单库写TPS是5000你的业务QPS到2万光ID生成这一项就会消耗掉接近一半的数据库写能力这在线上是不划算的。第二是主从架构下的ID回退风险。自增ID在主库生成从库同步数据时可能因为延迟导致读到的当前最大值偏小。一旦发生主从切换新主库没有同步到之前生成过的大ID业务重新插入时就会复用旧ID造成严重的主键冲突或数据错乱。这个问题在自增模式下很难彻底规避。第三是扩展性差。分库分表之后单表的自增ID会重复必须改造为分区自增或者引入全局ID生成器改造成本和风险都不低。所以当业务对ID的需求从“能生成”变成“高并发、趋势递增、跨实例不重复”时自增ID就不再是可靠的默认选项了。1.2 号段模式与雪花算法的取舍雪花算法是目前被讨论最多的替代方案它的核心思想是把64位ID拆成时间戳、机器ID和序列号三部分通过位运算在本地生成ID完全不需要查数据库。听起来很完美但落地时你会发现它也有硬伤首先依赖机器时钟如果发生时钟回拨哪怕只回拨1毫秒都有极小概率生成重复ID其次机器ID的分配和运维要自己管理尤其是在容器化环境里实例频繁重建机器ID分配就成了新的运维负担最后雪花ID虽然整体趋势递增但跨节点的ID大小顺序和真实请求顺序并不完全一致在某些对“时间有序性”敏感的存储结构里会有性能损耗。号段模式的思路则完全不同。它不追求每次生成ID都要和数据库交互而是先从数据库拿下一整段号码在应用内存里慢慢派发用完了再换下一段。这个思路的好处有三点数据库交互频率大幅下降。一次取号操作可以换来成千上万个IDDB压力从“每请求一次”变成“每N个请求一次”。ID趋势递增且单调。同一实例内派发的ID严格递增跨实例的ID区间也有序适合做索引和冷热数据归档。实现逻辑简单可控。不依赖时钟不依赖机器ID核心就是一个Update语句加一段内存分配逻辑。当然号段模式也有它的代价号码序列不是严格连续的中间会有空洞每个实例在重启时会丢弃本地尚未用完的号段造成一定浪费跨实例之间ID的精确先后顺序无法保证。这些代价在大多数业务场景里都是可以接受的因此号段模式在中小团队和中等并发场景下反而是比雪花算法更务实的选择。1.3 号段模式的基本运行模型用一个具体例子说明。假设数据库表中有一行记录id_keymax_idstepversionorder1000050001当第一个应用实例申请号段时执行一条原子更新把max_id从10000加到15000version从1变成2。然后这个实例就拿到了[10001, 15000]这段号码在内存里按顺序派发。第二个应用实例再来申请时数据库里的max_id已经是15000它执行同样的更新拿到[15001, 20000]这段号。两个实例各自持有一段互不重叠的号码池谁也不会重复发号。关键点在于数据库里的更新操作必须是原子的并且要把“更新并拿到新值”这两个动作绑定在一起否则并发情况下会出现同一段号被两个实例同时拿走的问题。这也是后文代码实现中最重要的一环。2. 表结构与取号原语所有实现的地基2.1 号段表到底怎么设计号段模式对数据库表的要求非常轻核心就一张表每行对应一种业务ID类型。以订单ID为例表结构可以这样定义CREATE TABLE id_allocator ( id_key VARCHAR(64) NOT NULL COMMENT 业务标识如order、short_url, max_id BIGINT NOT NULL COMMENT 当前已分配到的最大ID, step INT NOT NULL COMMENT 号段步长每次取号分配多少个数, version INT NOT NULL COMMENT 乐观锁版本号每次更新加1, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id_key) ) ENGINEInnoDB COMMENT分布式ID号段分配表;这张表的字段看起来简单但每个字段都有设计原因。id_key作为主键让每个业务ID类型独立管理自己的号段max_id保存当前已经分配到的最大IDstep控制每次取号的跨度version不是必须的但它可以让你在并发冲突时快速发现并重试比只靠max_id判断要可靠得多。实际生产环境中我会额外加一两个字段比如description用来备注业务含义biz_env用来区分测试和线上环境。不要让一张表承载过多业务标识否则某个业务突增流量会牵连其他业务的取号延迟。2.2 原子取号SQL为什么必须这样写取号操作是整个机制的核心它的SQL写法直接决定了并发安全。我见过一些团队把取号写成SELECT max_id FROM id_allocator WHERE id_key ?然后在应用层加1再UPDATE回去还加了事务。这种做法在低并发下能运行但并发一高就会出现两个线程读到同一个max_id各自加1后再更新最终导致号段重叠。正确的写法是把“查到新值”和“更新原值”合并为一条原子操作UPDATE id_allocator SET max_id max_id step, version version 1 WHERE id_key ? AND version ?;这条SQL执行成功后数据库已经保证了max_id的递增是原子的。接下来再查一次当前行的max_id就能得到本次分配到的号段上限。这里有一个容易忽略的点UPDATE后必须立刻查询并且查询和更新不能分离到不同连接上否则如果走从库读到旧值就会把已经分配给别人的号段再分配一次。如果UPDATE影响行数为0说明version已经被其他线程修改过当前线程的竞争失败了。这时候的处理逻辑很简单重新查一次最新的version和max_id再重试更新。重复这个循环直到成功。2.3 步长step怎么算步长step是号段模式最重要的调优参数。它的大小决定了两个事情一是数据库被更新的频率二是一次异常情况下可能浪费的号码数量。先说更新频率。假设业务峰值QPS是10000每次请求消耗一个ID那么每秒需要生成的ID数是10000。如果step1000意味着每消耗完1000个ID就要去数据库取一次新号段数据库每秒要处理10次更新。10次对数据库来说压力很小但如果step100每秒就要更新100次这个频率虽然也不算高可一旦有多个实例同时去更新冲突概率会明显上升。再算浪费。如果服务重启当前实例尚未派发完的号段会被整体丢弃。step10000时最坏情况会浪费9999个IDstep1000时最坏浪费999个。对大部分业务来说ID浪费不是问题只要不重复就行。我通常用这样一个简单公式来估算最小step最小step 峰值QPS × 单次数据库取号耗时假设单次取号耗时10ms峰值QPS是10000那么最小step 10000 × 0.01 100也就是100个。但实际我会在这个基础上留至少5倍余量也就是500到1000。因为数据库耗时会有波动连接池排队、锁冲突都会让单次取号时间变长如果step算得太紧高并发时应用还是会频繁阻塞在取号上。3. 核心代码实现从单缓冲到双缓冲3.1 一段号的本地模型先从最简单的数据模型开始。我们可以把一段号看成一个左闭右闭区间[start, end]start是这一段的起始IDend是结束ID本地用一个原子变量指向下一个准备派发的编号。public class Segment { private final long start; private final long end; private final AtomicLong current; public Segment(long start, long end) { this.start start; this.end end; this.current new AtomicLong(start); } public long nextId() { return current.getAndIncrement(); } public boolean isExhausted() { return current.get() end; } public long getEnd() { return end; } public long getStart() { return start; } }这里有个细节值得注意nextId()返回的是当前值然后自增当返回值为end 1时实际上已经超出这个号段了。调用方必须用id end来判断这个ID是否有效不能用isExhausted()提前判断后就认为一定安全因为多线程场景下isExhausted()和nextId()之间存在竞争窗口。把边界判断放在调用方统一处理逻辑更清晰也能减少一个分支的开销。3.2 单缓冲版本用完就去数据库抢新号段先写一个最基础的实现不做什么优化把核心逻辑跑通。public class IdAllocator { private final JdbcTemplate jdbcTemplate; private final String idKey; private final int step; private volatile Segment segment; public IdAllocator(JdbcTemplate jdbcTemplate, String idKey, int step) { this.jdbcTemplate jdbcTemplate; this.idKey idKey; this.step step; this.segment loadNextSegment(); } public long nextId() { while (true) { Segment currentSegment segment; long id currentSegment.nextId(); if (id currentSegment.getEnd()) { return id; } // 当前号段耗尽了需要重新加载 synchronized (this) { // 双重检查防止多个线程同时加载 if (segment currentSegment) { segment loadNextSegment(); } } } } private Segment loadNextSegment() { while (true) { int rows jdbcTemplate.update( UPDATE id_allocator SET max_id max_id ?, version version 1 WHERE id_key ? AND version ?, step, idKey, currentVersion() ); if (rows 0) { // 版本冲突重试 continue; } long maxId jdbcTemplate.queryForObject( SELECT max_id FROM id_allocator WHERE id_key ?, Long.class, idKey ); return new Segment(maxId - step 1, maxId); } } private long currentVersion() { return jdbcTemplate.queryForObject( SELECT version FROM id_allocator WHERE id_key ?, Long.class, idKey ); } }这段代码有两个关键点。第一nextId()里用了一个while (true)循环因为currentSegment.nextId()可能在号段刚好耗尽时返回一个end1的无效ID这时不能直接返回必须重新加载号段再走一遍。第二加载号段的逻辑放在synchronized块里并且用segment currentSegment做了二次检查确保只有一个线程去执行数据库更新其他线程只在原地等待。单缓冲版本的问题在于当号段用完的那一瞬间所有并发线程都会阻塞在synchronized块或者等待数据库返回这会造成明显的响应毛刺。假设数据库取号耗时20ms那么这20ms内所有请求都会卡住对高并发系统来说是不可接受的。3.3 双缓冲改造用预取消除取号毛刺为了解决单缓冲刷新时的阻塞问题业界普遍采用双缓冲方案。核心思想是维护两个Segment一个用于当前派发一个在后台预先加载好。当当前号段消耗到一定比例时后台线程自动去数据库取下一段号等当前号段彻底用完时备用段直接顶上整个切换过程零阻塞。public class IdAllocator { private final JdbcTemplate jdbcTemplate; private final String idKey; private final int step; private final int loadThreshold; private final ExecutorService loadExecutor; private final AtomicBoolean loading new AtomicBoolean(false); private volatile Segment currentSegment; private volatile Segment standbySegment; public IdAllocator(JdbcTemplate jdbcTemplate, String idKey, int step, int loadThreshold) { this.jdbcTemplate jdbcTemplate; this.idKey idKey; this.step step; this.loadThreshold loadThreshold; this.loadExecutor Executors.newSingleThreadExecutor(r - { Thread t new Thread(r, id-allocator-loader); t.setDaemon(true); return t; }); this.currentSegment loadNextSegment(); // 启动后立即预取下一段 asyncLoadNextSegment(); } public long nextId() { while (true) { Segment seg currentSegment; long id seg.nextId(); if (id seg.getEnd()) { maybeTriggerAsyncLoad(seg); return id; } synchronized (this) { if (currentSegment seg) { if (standbySegment null) { // 备用段还没加载好只能同步等待 standbySegment loadNextSegment(); } currentSegment standbySegment; standbySegment null; // 切换后立即触发下一次预载 asyncLoadNextSegment(); } } } } private void maybeTriggerAsyncLoad(Segment seg) { long used seg.getCurrent() - seg.getStart(); long total seg.getEnd() - seg.getStart() 1; if (total - used loadThreshold loading.compareAndSet(false, true)) { loadExecutor.submit(this::asyncLoadTask); } } private void asyncLoadTask() { try { Segment newSegment loadNextSegment(); synchronized (this) { if (standbySegment null) { standbySegment newSegment; } } } finally { loading.set(false); } } private void asyncLoadNextSegment() { if (loading.compareAndSet(false, true)) { loadExecutor.submit(this::asyncLoadTask); } } private Segment loadNextSegment() { while (true) { int rows jdbcTemplate.update( UPDATE id_allocator SET max_id max_id ?, version version 1 WHERE id_key ? AND version ?, step, idKey, currentVersion() ); if (rows 0) { continue; } Long maxId jdbcTemplate.queryForObject( SELECT max_id FROM id_allocator WHERE id_key ?, Long.class, idKey ); return new Segment(maxId - step 1, maxId); } } }双缓冲的关键点在于触发预取的时机。我在代码里用loadThreshold表示剩余号码的警戒线比如总数是10000loadThreshold设为2000那么当剩余号码小于2000时就触发后台加载。这个机制保证了当前号段还有余量时下一段已经在备用槽位里等着了。还有一个容易被忽视的细节预取线程池必须是单线程不能用newFixedThreadPool(4)这类多线程池。因为多个线程同时执行loadNextSegment()会导致数据库被更新多次一次预取就浪费几个step的号码还会增加冲突重试的概率。这里用AtomicBoolean loading作为全局开关确保任何时刻最多只有一个预取任务在运行。切换逻辑本身不复杂但要注意synchronized和volatile的配合。currentSegment和standbySegment都必须声明为volatile这样在其他线程调用nextId()时才能立即看到引用变化。切换完成后要立刻触发下一次预取否则当前段又会被动阻塞。3.4 触发时机为什么设在20%而不是50%loadThreshold的取值直接影响预取效果和数据库压力。设得太大比如50%意味着当前段刚用掉一半就开始预取下一段备用段会在内存里闲置较长时间如果业务突然降流量这段号可能直到重启都没被用过造成浪费。设得太小比如5%意味着预取动作被压缩到号段末尾才触发只要数据库耗时波动大一点后台线程来不及在耗尽前准备好备用段调用线程还是会同步阻塞。我实际使用中一般设在20%到30%之间。这是一个兼顾安全性和浪费率的经验区间。如果数据库取号平均耗时5ms、波动不大20%足够覆盖如果数据库负载高、耗时波动到50ms以上我会把阈值提高到40%宁可多浪费一点号码也要保证线上不发卡顿。4. 参数调优和部署细节这些坑必须提前知道4.1 边界条件处理号段模式最容易被忽略的边界条件是Long.MAX_VALUE溢出。虽然现实中单个ID很难用到922京但作为基础组件防御性编程还是要做。比如loadNextSegment()里更新前可以检查一下当前maxId加上step是否溢出如果溢出应返回一个明确的错误或者触发告警而不是默默算出负数。另一个边界是号段切换时的重合判断。在单缓冲版本里id seg.getEnd()这个判断决定了返回的ID是否有效。如果写成了id seg.getEnd()会导致最后一个ID永远发不出去每次都是无效ID然后反复触发号段重载。这种bug不会崩溃但会造成大量无效数据库访问性能影响很大。我在代码审查里见过这个问题所以特意提出来。还有一个和数据库相关的边界取号操作必须在同一个连接上完成UPDATE和SELECT。如果你的代码把更新和查询拆成两个方法各自从连接池拿连接那么更新落库后查询可能被路由到从库。从库同步延迟哪怕只有几毫秒就可能读到旧值旧值会导致内存中的号段和数据库实际分配区间不一致轻则浪费号码重则重复发号。4.2 多实例部署时step要放大当服务按多实例部署时每个实例都会独立持有一段号。假设两个实例同时启动它们会分别拿到两个互不重叠的号段这没有问题。但有代价每个实例都会在某个时间点触发数据库更新数据库的更新频率是“所有实例预取频率之和”。更关键的是如果某个实例长期处于低流量状态它持有的号段会慢慢消耗而高流量实例可能很快就把自己的号段用完并频繁取号。为了让所有实例都能稳定运行step应该适当放大。通常我会把step设为“单实例最小step × 实例数”并加一个2到3倍的弹性系数。比如单实例最小step是1000部署5个实例那么step设为10000左右比较合理。4.3 监控指标与预警运行号段模式组件至少要监控三个指标当前实例剩余号码量、数据库取号耗时的TP99、预取触发次数。剩余号码量直接决定系统是否可能进入同步阻塞状态。可以在nextId()返回时按剩余量分级打日志比如剩余小于20%打INFO小于5%打WARN小于1%打ERROR。取号耗时TP99超过50ms时要检查是不是数据库连接池满了或者UPDATE语句发生了锁等待。预取触发次数异常低时说明ID消耗速度低于预期有可能是业务量下滑也可能是步长设置过大需要结合业务数据一起判断。我自己的做法是在IdAllocator里挂一个MetricRegistry把nextId调用量、有效ID数、异步加载次数都统计出来接入Prometheus的Gauge和Counter。上线初期我还会把每次加载号段前后的时间戳打出来确认预取线程的运行节奏是否符合预期。5. 常见问题与排查实例我从线上踩过的坑5.1 重启会重复发号吗这是很多初次接触号段模式的人最担心的问题。答案是不会。因为数据库里的max_id在每次取号时都已经前移了一段重启只会丢弃本地尚未派发的号码不会把已经发出去的ID再发一次。新实例启动时会基于数据库当前max_id重新取号所以生成的ID严格递增且不重复。但要注意重启会导致号码空洞。比如原实例拿到了[10001, 15000]只发到12000就宕机了剩下的3000个ID永远不会再被使用。如果业务对ID连续性有强要求号段模式是不合适的如果只要求不重复和趋势递增这个特性完全可以接受。5.2 主从延迟导致的重复发号我曾经遇到过一次发号重复的线上事故排查后发现不是取号SQL的问题而是查询时走到了从库。当时代码里loadNextSegment()先更新主库然后通过另一个方法查询数据库那个方法用的数据源做了读写分离查询被路由到了从库。主库更新到从库同步有几十毫秒延迟于是查询返回的还是旧值应用层把旧值当成新号段上线与另一个实例持有了重叠区间。解决方式很简单把UPDATE和SELECT放到同一个方法里并且确保它们使用同一个数据库连接。用Spring的TransactionTemplate包住这两个操作或者通过JdbcTemplate.execute在同一个连接内完成。只要不隔着数据源路由层就不会碰到主从延迟问题。5.3 高并发下的毛刺与连接池耗尽单缓冲版本上线后最明显的现象是每过一段时间nextId()的耗时就会出现一个尖刺尖刺的宽度正好是数据库取号的耗时。原因是当号段耗尽时所有请求线程同时卡在加载逻辑上。这个尖刺不仅影响ID生成还会拖慢下游线程严重时连接池会被占满。双缓冲上线后这个现象基本消失但前提是预取线程能及时完成加载。连接池耗尽的另一个触发点是重试风暴。如果数据库短时间内出现锁冲突多个实例同时执行UPDATE只有一个会成功其他实例会立即重试。每次重试都会发起新的数据库请求把连接池打满。解决方案是在重试循环里加一个短暂的随机退避比如Thread.sleep(10 random.nextInt(50))降低并发重试的压力。5.4 一个压测数据参考我拿双缓冲版本做过一次基准压测环境是8核16G容器、MySQL8.0、step5000、阈值20%。单实例纯内存派发阶段不触发数据库加载QPS能到50万以上这基本不是瓶颈触发后台预取时只要预取线程及时完成主链路完全感知不到数据库耗时整体QPS能稳定在10万左右。如果把阈值调到5%预取线程偶尔来不及主链路就会间歇性出现同步取号阻塞QPS会掉到2万左右并伴随大量耗时尖刺。这说明双缓冲的设计本身是可靠的但前提是参数得给够。step和loadThreshold都是为预取服务的宁可让备用号段多闲置一会儿也不能让主线程去等数据库。6. 最后分享一点个人体会号和雪花算法之争已经持续很久了我的态度很明确中小团队、业务峰值在十万QPS以内的系统号段模式往往比雪花算法更省心。它不需要维护机器ID不依赖时钟部署多实例时只需要一条数据库记录就能保证全局不重复。真正需要细致的不是概念而是代码里那几个边界判断和参数设置。如果你刚从自增ID切换过来我建议按顺序做三件事先把单缓冲版本跑通确认数据库更新和查询同连接然后加上双缓冲把预取阈值设在20%最后用生产流量做一次压测把step调到数据库取号频率不超过每秒10次的水平。做完这三步这个组件基本可以稳定跑很久之后再遇到问题第一反应查主从同步第二反应查预取参数基本都能定位到原因。
返回列表