这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
号段模式
号段模式(Segment)是一种高性能的分布式 ID 生成方案,它通过预分配 ID 号段来实现高性能。
1. 号段定义
号段: 一段连续的 ID 范围,例如 [1000, 2000]。
工作流程:
1. 从数据库获取号段:[1000, 2000]
2. 本地分配 ID:1001, 1002, 1003, ...
3. 号段用完,获取新号段:[2000, 3000]
4. 重复步骤 2-3
2. 号段表设计
表结构:
数据设计要点
- 核心是在
id_segment里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、business_type、max_id、step、version、created_at、updated_at,它们决定后续查询和管理能力。
初始化数据:
数据设计要点
- 关键字段包括
INSERT,它们决定后续查询和管理能力。
1. 获取号段
获取号段是整个流程里唯一一次和数据库打交道的动作。做法是在一个事务里给号段记录加行锁,读出当前水位,再把水位往后推一整段。
Java 实现:
/**
* 号段获取器:向数据库预占一段号段。
*/
public class SegmentDao {
/** 加行锁读取当前水位,保证并发预占不会重叠。 */
private static final String SELECT_SQL =
"SELECT max_id, step FROM id_segment WHERE business_type = ? FOR UPDATE";
/** 把水位写回去,version 用于排查是谁改的。 */
private static final String UPDATE_SQL =
"UPDATE id_segment SET max_id = ?, version = version + 1 WHERE business_type = ?";
private final DataSource dataSource;
public SegmentDao(DataSource dataSource) {
this.dataSource = dataSource;
}
/**
* 预占一段号段,返回区间的左右端点(闭区间)。
*/
public long[] fetchSegment(String businessType) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try {
long oldMax;
long currentStep;
try (PreparedStatement ps = conn.prepareStatement(SELECT_SQL)) {
ps.setString(1, businessType);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("号段记录不存在: " + businessType);
}
oldMax = rs.getLong("max_id");
currentStep = rs.getLong("step");
}
}
long newMax = oldMax + currentStep;
try (PreparedStatement ps = conn.prepareStatement(UPDATE_SQL)) {
ps.setLong(1, newMax);
ps.setString(2, businessType);
ps.executeUpdate();
}
conn.commit();
return new long[] { oldMax + 1, newMax };
} catch (SQLException e) {
conn.rollback();
throw e;
}
}
}
}
三个细节决定了这段代码是否正确:
- 行锁
FOR UPDATE让并发预占串行化。同一个业务类型下,两个实例不会拿到重叠的区间。 - 整段操作放在一个事务里。读水位和写水位之间不能被别的事务插进来,否则区间会算错。
- 区间是闭区间
(oldMax, newMax],长度正好是step。步长在号段表里按业务类型配置,核心业务用小步长少浪费,日志类业务用大步长少访问数据库。
2. 号段生成器
号段生成器是应用侧持有的组件:它把「取号段」和「发号」两件事分开,对外只暴露一个 nextId()。
Java 实现:
/**
* 号段生成器:本地发放号段,用完再向数据库预占下一段。
*/
public class SegmentIdGenerator {
private final SegmentDao segmentDao;
private final String businessType;
/** 当前号段的下一个待发 ID。 */
private long nextId;
/** 当前号段的右端点(含)。 */
private long maxId;
/** 是否已经取过号段。 */
private boolean loaded;
public SegmentIdGenerator(SegmentDao segmentDao, String businessType) {
this.segmentDao = segmentDao;
this.businessType = businessType;
}
/**
* 取下一个 ID;号段用完时自动预占下一段。
*/
public synchronized long nextId() throws SQLException {
if (!loaded || nextId > maxId) {
load();
}
return nextId++;
}
/** 向数据库预占下一个号段。 */
private void load() throws SQLException {
long[] range = segmentDao.fetchSegment(businessType);
this.nextId = range[0];
this.maxId = range[1];
this.loaded = true;
}
}
使用时有三点要注意:
- 生成器在应用里是单例,一个业务类型一个实例。多个实例各自持有自己的号段,互不干扰。
- 取号是本地自增,只有号段用完时才会阻塞一次。发号频率再高,数据库的压力也只取决于「号段消耗速度 ÷ 步长」。
synchronized保证多线程下发号时不会拿到同一个 ID。生产上可以用原子变量进一步降低争用,但正确性前提是一样的:取出和推进必须是一个原子动作。
1. 原理
使用两个号段 Buffer,一个当前使用,一个预加载。
架构:
┌─────────────────┐ ┌─────────────────┐
│ Buffer 1 │ │ Buffer 2 │
│ [1000, 2000] │ │ [2000, 3000] │
│ 当前使用 │ │ 预加载 │
└─────────────────┘ └─────────────────┘
↓ ↓
分配 ID 等待切换
2. 实现
双 Buffer 在代码里就是两个存放号段的引用加一个后台预加载任务,切换时只做一次原子替换。
Java 实现:
/**
* 双 Buffer 取号器:一个号段在发放,另一个在后台预加载。
*/
public class DoubleBufferIdGenerator {
/** 当前号段剩余量低于这个值就触发预加载。 */
private static final int PRELOAD_THRESHOLD = 100;
private final SegmentDao segmentDao;
private final String businessType;
/** 当前正在发放的号段。 */
private long currentMin;
private long currentMax;
/** 预加载好的备用号段。 */
private long standbyMin;
private long standbyMax;
/** 备用号段是否已经就绪。 */
private boolean standbyReady;
/** 保证同一时刻只有一个预加载任务在跑。 */
private final AtomicBoolean preloading = new AtomicBoolean(false);
private final ExecutorService executor = Executors.newSingleThreadExecutor();
public DoubleBufferIdGenerator(SegmentDao segmentDao, String businessType) {
this.segmentDao = segmentDao;
this.businessType = businessType;
}
/**
* 取下一个 ID;当前号段用完时切到备用号段。
*/
public synchronized long nextId() throws SQLException {
if (currentMax < currentMin) {
if (standbyReady) {
// 切换:只是把备用号段提升为当前号段
currentMin = standbyMin;
currentMax = standbyMax;
standbyReady = false;
} else {
// 备用号段还没到,退化为同步取号,本次请求要等
long[] range = segmentDao.fetchSegment(businessType);
currentMin = range[0];
currentMax = range[1];
}
}
long id = currentMin++;
if (currentMax - currentMin < PRELOAD_THRESHOLD) {
preload();
}
return id;
}
/** 后台预加载下一个号段,失败不影响当前号段的发放。 */
private void preload() {
if (!preloading.compareAndSet(false, true)) {
return;
}
executor.execute(() -> {
try {
long[] range = segmentDao.fetchSegment(businessType);
synchronized (this) {
standbyMin = range[0];
standbyMax = range[1];
standbyReady = true;
}
} catch (SQLException e) {
// 预加载失败:保留当前号段,下次取号时再试
} finally {
preloading.set(false);
}
});
}
}
这段代码里有四条纪律:
- 切换动作只做指针替换,不做任何网络访问,所以切换耗时不到 1ms。
- 预加载任务用
preloading标记互斥,避免同一个时刻重复申请、浪费号段。 - 预加载失败只丢弃这一次的备用号段,当前号段照常发放,不会把失败传导给业务请求。
- 备用号段没就绪时会退化成同步取号。这是最坏情况下的降级路径:有等待,但没有错误,更不会重复发号。
1. 性能优势
✅ 本地分配 ID,无网络开销
✅ 批量获取,减少数据库访问
✅ 双 Buffer,零等待
✅ 高并发支持
2. 性能测试
单线程:1,000,000 QPS
多线程(10):5,000,000 QPS
多线程(100):10,000,000 QPS
延迟:< 0.001ms
✅ 适合使用
1. 超高并发系统
QPS > 1,000,000
极低延迟要求
2. 大规模系统
用户量 > 1000万
需要高可用
❌ 不适合使用
1. 小型系统
复杂度高
维护成本高
号段模式评价
| 维度 | 评分 | 说明 |
|---|---|---|
| 唯一性 | ⭐⭐⭐⭐⭐ | 数据库保证 |
| 有序性 | ⭐⭐⭐⭐⭐ | 严格递增 |
| 性能 | ⭐⭐⭐⭐⭐ | 超高性能 |
| 可用性 | ⭐⭐⭐⭐ | 需要数据库 |
| 扩展性 | ⭐⭐⭐⭐ | 分布式友好 |
使用建议
强烈推荐:
✅ 超高并发系统
✅ 大规模系统
✅ 需要极致性能
了解了号段模式后,我们学习系统架构设计。
👉 下一节:设计原则
发现内容有问题?
告诉我们哪里需要改进。
