前言:
在构建高并发、分布式系统时,我们常常需要生成全局唯一的 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;
}
}
}
六、使用建议
-
合理设置
step:- 小型应用:1 万 ~ 5 万
- 中大型应用:10 万 ~ 100 万
- 过大会导致 ID 浪费(重启丢失未用完的号段)
-
监控号段切换频率:
- 通过日志或指标监控
fetchRange()调用次数 - 如果太频繁,说明
step太小
- 通过日志或指标监控
-
支持多业务线隔离:
- 不同业务使用不同
bizTag(如"order","user") - 避免相互影响
- 不同业务使用不同
-
未来优化方向:
- 当
current使用到 80% 时,异步线程 预加载next - 支持动态调整
step - 增加重试机制应对数据库临时故障
- 当
七、总结与注意事项
号段模式是一种简单却极其有效的分布式 ID 生成方案。它巧妙地平衡了性能、一致性、可靠性三大要素,特别适合短链、订单、消息等需要趋势递增、全局唯一 ID 的场景。
另外,号段模式也不是完美的,存在几个明显的缺陷:
- 生成的ID无法保证连续:只要服务器重启了,就会丢失未分配的id
- ID完全基于累加,极易被枚举,攻击者可通过观察几个 ID 推测出总量、增长速度,甚至遍历资源
- ID占用情况严重:申请10w个ID就有10w个id被占用
- 无法保证全局时序性,只能保证当前申请的号段下id的时序性