号段模式(分布式ID)上手指南:从原理到实战

前言

在构建高并发、分布式系统时,我们常常需要生成全局唯一的 ID。传统的数据库自增主键在单机场景下表现良好,但在分布式环境下容易成为性能瓶颈或出现重复 ID 的问题。号段模式(Segment ID) 正是为了解决这类问题而诞生的一种高效、可靠的 ID 生成策略。本文将带你从零开始,深入理解号段模式的原理,并手把手实现一个生产可用的 Java 版本。

一、什么是号段模式?

号段模式的核心思想是:不再每次生成 ID 都访问数据库,而是批量"预取"一段连续的 ID 到内存中使用

举个生活中的例子:

你去银行取号,工作人员不是每次只给你一张号码纸,而是直接递给你一本 100 张的号段本。你可以在接下来的一段时间内自己撕下号码使用,用完再回去领新的一本。这样既减少了排队次数,又保证了号码不重复。

在技术上:

  • 每次从数据库申请 step 个 ID(比如 10 万)
  • 应用本地用原子变量递增分配
  • 当前号段快用完时,异步或同步申请下一段
  • 所有状态通过数据库持久化,支持多实例部署和重启恢复

二、为什么选择号段模式?

  • 高性能:99% 的 ID 获取是纯内存操作(纳秒级)
  • 强一致性:依赖数据库事务和行锁,保证多实例下绝对唯一
  • 无中心节点:每个服务实例独立工作,无单点故障
  • 可扩展 :通过增加 step 大小轻松应对更高吞吐
  • 简单可靠:逻辑清晰,易于理解和维护

相比雪花算法(Snowflake),它不依赖系统时钟,避免了时钟回拨问题;相比 UUID,它生成的是趋势递增的数字 ID,更适合数据库索引。


三、数据表设计

首先,我们需要一张数据库表来记录每个业务标签(bizTag)当前的最大 ID。

sql 复制代码
CREATE TABLE id_segment (
    biz_tag VARCHAR(64) PRIMARY KEY,  -- 业务标识,如 'short_url', 'order_id'
    max_id  BIGINT NOT NULL,         -- 当前已分配的最大 ID
    step    INT NOT NULL              -- 每次申请的步长
);

建议:biz_tag 必须是唯一主键,这是并发安全的关键。


四、核心逻辑详解

1. 初始化:确保记录存在

当第一次使用某个 bizTag 时,需在数据库中创建初始记录:

java 复制代码
if (!repo.existsById(bizTag)) {
    try {
        repo.save(new IdSegment(bizTag, step));
    } catch (Exception ignored) {
        // 并发插入可能失败,由 DB 唯一约束兜底,后续仍可正常 fetch
    }
}

这里允许多个实例同时尝试插入,但只有第一个成功,其余因主键冲突失败,不影响后续流程。


2. 申请新号段:原子更新 + 查询

这是号段模式最关键的一步,必须保证原子性

sql 复制代码
UPDATE id_segment 
SET max_id = max_id + #{step} 
WHERE biz_tag = #{bizTag};

-- 然后查询新的 max_id
SELECT max_id FROM id_segment WHERE biz_tag = #{bizTag};

在 Spring Data JPA 中(你也可以换成Mybatis,这里用JPA举例更简单),你可以这样写:

java 复制代码
@Modifying
@Query("UPDATE IdSegment s SET s.maxId = s.maxId + :step WHERE s.bizTag = :bizTag")
int updateMaxId(@Param("bizTag") String bizTag, @Param("step") int step);

@Query("SELECT s.maxId FROM IdSegment s WHERE s.bizTag = :bizTag")
Long findMaxIdByBizTag(@Param("bizTag") String bizTag);

为什么安全?
UPDATE ... WHERE 会对匹配的行加排他锁(X Lock),确保同一时间只有一个事务能更新 max_id,从而避免重复分配。


3. 构建号段区间

假设 step = 100_000,更新后 max_id = 200_000,那么本次分配的号段就是:

  • 起始值:200_000 - 100_000 + 1 = 100_001
  • 结束值:200_000
  • 可用 ID:[100001, 100002, ..., 200000]

我们将这个区间封装为一个 Range 对象:

java 复制代码
class Range {
    private final long end;
    private final AtomicLong cursor; // 当前已分配的位置

    Range(long start, long end) {
        this.end = end;
        this.cursor = new AtomicLong(start - 1); // 下一次 get 就是 start
    }

    long next() {
        long v = cursor.incrementAndGet();
        return v <= end ? v : -1; // -1 表示耗尽
    }
}

4. 双缓冲机制:避免切换阻塞

为了在当前号段用完时不阻塞用户请求,我们采用"双缓冲"策略:

  • current:正在使用的号段
  • next:预加载的下一个号段

current 耗尽时,立即切换到 next,然后在同步方法中再去申请新的 next(未来可优化为异步)。

java 复制代码
synchronized long nextId() {
    long id = current.next();
    if (id != -1) return id;

    // 切换到预加载段
    current = next;
    next = allocateNewRange(); // 同步拉取新段
    return current.next();
}

虽然切换时仍会短暂阻塞,但由于 step 足够大(如 10 万),切换频率极低(每 10 万个请求才一次),对整体性能影响微乎其微。


五、完整代码实现(Spring Boot+JPA举例)

1. 实体类

java 复制代码
@Entity
@Table(name = "id_segment")
public class IdSegment {
    @Id
    private String bizTag;
    private Long maxId;
    private Integer step;
}

2. Repository

java 复制代码
public interface IdSegmentRepository extends JpaRepository<IdSegment, String> {
    @Modifying
    @Query("UPDATE IdSegment s SET s.maxId = s.maxId + :step WHERE s.bizTag = :bizTag")
    int updateMaxId(@Param("bizTag") String bizTag, @Param("step") int step);

    @Query("SELECT s.maxId FROM IdSegment s WHERE s.bizTag = :bizTag")
    Long findMaxIdByBizTag(@Param("bizTag") String bizTag);
}

3. ID 生成器

Java 复制代码
@Service
public class SegmentIdGenerator implements IdGenerator {

    private  final IdSegmentRepository segmentRepo;

    private final Map<String, Buffer> bufferCache = new ConcurrentHashMap<>();

    public SegmentIdGenerator(IdSegmentRepository segmentRepo) {
        this.segmentRepo = segmentRepo;
    }

    @Override
    public long nextId(String bizTag) {
        // 每个 bizTag 对应一个 Buffer(懒加载)
        return bufferCache.computeIfAbsent(bizTag, Buffer::new).nextId();
    }

    /**
     * 每个业务标签(bizTag)对应一个独立的 ID 缓冲区
     */
    class Buffer {
        private final String bizTag;
        private static final int STEP = 100_000; // 每次预取 10w 个 ID

        // 双缓冲:current 正在使用,next 预加载(避免切换时阻塞)
        private volatile Range currentRange;
        private volatile Range nextRange;

        Buffer(String bizTag) {
            this.bizTag = bizTag;
            initialize(); // 初始化两个号段
        }

        /**
         * 获取下一个可用 ID
         */
        synchronized long nextId() {
            long id = currentRange.next();
            if (id != -1) {
                return id; // 当前号段还有余量
            }

            // 当前号段耗尽,切换到预加载的下一段
            currentRange = nextRange;
            nextRange = allocateNewRange(); // 同步拉取新段(可后续优化为异步)
            return currentRange.next();
        }

        /**
         * 初始化:确保 DB 有记录,并加载前两段
         */
        private void initialize() {
            ensureSegmentExistsInDb();
            this.currentRange = allocateNewRange();
            this.nextRange = allocateNewRange();
        }

        /**
         * 确保数据库中存在该 bizTag 的记录(首次使用时创建)
         */
        private void ensureSegmentExistsInDb() {
            // 先检查是否存在
            if (!segmentRepo.existsById(bizTag)) {
                try {
                    IdSegment segment = new IdSegment();
                    segment.setBizTag(bizTag);
                    segment.setMaxId(1L);
                    segment.setStep(STEP);
                    segmentRepo.save(segment);
                } catch (Exception ex) {
                    // 并发场景下可能多个实例同时插入,由 DB 唯一约束保证最终只有一个成功
                    // 这里忽略异常,后续 fetch 仍能成功
                }
            }
        }

        /**
         * 从数据库原子地申请一个新的 ID 号段 [start, end]
         */
        private Range allocateNewRange() {
            // 1. 原子更新 max_id(关键:DB 行锁保证并发安全)
            int updatedRows = segmentRepo.updateMaxId(bizTag, STEP);
            if (updatedRows == 0) {
                throw new RuntimeException("Failed to update max_id for bizTag: " + bizTag);
            }

            // 2. 查询更新后的 max_id(即新区间的结束值)
            Long newMaxId = segmentRepo.findMaxIdByBizTag(bizTag);
            if (newMaxId == null) {
                throw new IllegalStateException("Segment record missing after update: " + bizTag);
            }

            long end = newMaxId;
            long start = end - STEP + 1;
            return new Range(start, end);
        }
    }

    /**
     * 表示一个连续的 ID 区间 [start, end]
     */
    static class Range {
        private final long end;
        private final AtomicLong cursor; // 当前已分配到的位置

        Range(long start, long end) {
            this.end = end;
            this.cursor = new AtomicLong(start - 1); // 下一次 incrementAndGet 得到 start
        }

        /**
         * 返回下一个 ID,若耗尽返回 -1
         */
        long next() {
            long value = cursor.incrementAndGet();
            return (value <= end) ? value : -1;
        }
    }
}

六、使用建议

  1. 合理设置 step

    • 小型应用:1 万 ~ 5 万
    • 中大型应用:10 万 ~ 100 万
    • 过大会导致 ID 浪费(重启丢失未用完的号段)
  2. 监控号段切换频率

    • 通过日志或指标监控 fetchRange() 调用次数
    • 如果太频繁,说明 step 太小
  3. 支持多业务线隔离

    • 不同业务使用不同 bizTag(如 "order", "user"
    • 避免相互影响
  4. 未来优化方向

    • current 使用到 80% 时,异步线程 预加载 next
    • 支持动态调整 step
    • 增加重试机制应对数据库临时故障

七、总结与注意事项

号段模式是一种简单却极其有效的分布式 ID 生成方案。它巧妙地平衡了性能、一致性、可靠性三大要素,特别适合短链、订单、消息等需要趋势递增、全局唯一 ID 的场景。

另外,号段模式也不是完美的,存在几个明显的缺陷:

  1. 生成的ID无法保证连续:只要服务器重启了,就会丢失未分配的id
  2. ID完全基于累加,极易被枚举,攻击者可通过观察几个 ID 推测出总量、增长速度,甚至遍历资源
  3. ID占用情况严重:申请10w个ID就有10w个id被占用
  4. 无法保证全局时序性,只能保证当前申请的号段下id的时序性

相关文章:分布式 ID 生成策略全景图:UUID、号段、Snowflake、Leaf、TinyID,如何选型?

相关推荐
lkbhua莱克瓦242 小时前
Java基础——集合进阶用到的数据结构知识点3
java·数据结构·github·平衡二叉树·avl
烽学长2 小时前
(附源码)基于Spring boot的校园志愿服务管理系统的设计与实现
java·spring boot·后端
shark_chili2 小时前
硬核安利一个监控告警开源项目Nightingale
后端
拾忆,想起2 小时前
10分钟通关OSI七层模型:从光纤到APP的奇幻之旅
java·redis·网络协议·网络安全·缓存·哈希算法
IT_陈寒2 小时前
WeaveFox 全栈创作体验:从想法到完整应用的零距离
前端·后端·程序员
程序员爱钓鱼2 小时前
Python编程实战 - Python实用工具与库 - 正则表达式匹配(re 模块)
后端·python·面试
程序员爱钓鱼2 小时前
Python编程实战 - Python实用工具与库 - 爬取并存储网页数据
后端·python·面试
失散132 小时前
分布式专题——49 SpringBoot整合ElasticSearch8.x实战
java·spring boot·分布式·elasticsearch·架构
悟能不能悟2 小时前
java格式化BigDecimal為#,###,##0.00
java·css·css3