这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
集群模式
单库自增无法满足分布式系统的需求,让我们学习如何在数据库集群中生成唯一 ID。
1. 单库的限制
问题:
单数据库实例:
- 无法水平扩展
- 单点故障
- 性能瓶颈
影响:
- QPS 受限于单机性能
- 数据库故障导致系统不可用
- 无法支持大规模系统
1. 不同起始值
原理: 为每个数据库实例设置不同的起始值和相同的步长。
配置:
数据库 A:起始值 1,步长 3
数据库 B:起始值 2,步长 3
数据库 C:起始值 3,步长 3
生成的 ID:
数据库 A:1, 4, 7, 10, 13, ...
数据库 B:2, 5, 8, 11, 14, ...
数据库 C:3, 6, 9, 12, 15, ...
实现:
数据设计要点
- 核心是在
users里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、name,它们决定后续查询和管理能力。
方案落地:
2. 不同步长
原理: 为每个数据库实例设置相同的起始值和不同的步长。
配置:
数据库 A:起始值 1,步长 2
数据库 B:起始值 2,步长 2
生成的 ID:
数据库 A:1, 3, 5, 7, 9, ...
数据库 B:2, 4, 6, 8, 10, ...
实现:
数据设计要点
- 这里关注数据模型和约束关系,不需要记住具体语法。
1. 主从复制
架构:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 节点 A │ │ 节点 B │ │ 节点 C │
│ Master │ │ Master │ │ Master │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└────────────┼────────────┘
│
负载均衡器
实现:
2. 故障转移
策略:
1. 健康检查:定期检查数据库状态
2. 故障检测:快速发现故障数据库
3. 流量切换:将流量切换到健康数据库
4. 自动恢复:故障恢复后重新加入
实现:
1. 同步复制
问题:
主库写入后,从库同步需要时间
可能导致:
- 数据不一致
- ID 冲突
解决:
数据设计要点
- 这里关注数据模型和约束关系,不需要记住具体语法。
2. 最终一致性
策略:
1. 允许短暂不一致
2. 定期同步数据
3. 冲突检测和解决
4. 数据修复机制
1. 批量获取
集群模式解决的是唯一性,批量获取解决的是吞吐。做法是让每个实例一次向数据库预占一段 ID,而不是一个一个去取:本地把这段发完,再预占下一段。
预占的粒度必须和集群步长对齐。三个实例、步长 3 时,实例 A 拿到的是 1、4、7、10 这样间隔为 3 的序列,所以一批要按「步长 × 批大小」预留,才能既不与别的实例重叠、也不浪费号段。
表结构:
CREATE TABLE id_alloc (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
biz VARCHAR(64) NOT NULL,
instance_id INT NOT NULL,
offset_id BIGINT NOT NULL,
step INT NOT NULL,
max_id BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_biz_instance (biz, instance_id)
) ENGINE=InnoDB;
-- 三个实例:起始值分别是 1、2、3,步长都是 3
-- max_id 初始为「起始值 - 步长」,这样第一个 ID 正好是各自的起始值
INSERT INTO id_alloc (biz, instance_id, offset_id, step, max_id) VALUES ('order', 1, 1, 3, -2);
INSERT INTO id_alloc (biz, instance_id, offset_id, step, max_id) VALUES ('order', 2, 2, 3, -1);
INSERT INTO id_alloc (biz, instance_id, offset_id, step, max_id) VALUES ('order', 3, 3, 3, 0);
实现:
/**
* 集群模式下的批量取号器。
*
* 每个实例只在 id_alloc 表属于自己的那一行上取号,实例之间靠起始值与步长错开,
* 号段互不重叠;取号时按「步长 × 批大小」一次性预占一段区间,在本地逐个发放。
*/
public class ClusterBatchIdGenerator {
/** 加行锁读取该实例的发放进度,保证并发预占不会重叠。 */
private static final String SELECT_SQL =
"SELECT max_id, step FROM id_alloc WHERE biz = ? AND instance_id = ? FOR UPDATE";
/** 把预占结果写回该实例自己的那一行。 */
private static final String UPDATE_SQL =
"UPDATE id_alloc SET max_id = ? WHERE biz = ? AND instance_id = ?";
private final DataSource dataSource;
private final String biz;
private final int instanceId;
private final int batchSize;
/** 当前本地号段的右端点(含)。 */
private long maxId;
/** 当前本地号段的下一个待发 ID。 */
private long nextId;
/** 集群步长,等于实例数,从号段记录里读出来。 */
private long step;
/** 是否已经预占过号段。 */
private boolean loaded;
public ClusterBatchIdGenerator(DataSource dataSource, String biz, int instanceId, int batchSize) {
this.dataSource = dataSource;
this.biz = biz;
this.instanceId = instanceId;
this.batchSize = batchSize;
}
/** 取下一个 ID;本地号段用完时自动预占下一段。 */
public synchronized long nextId() throws SQLException {
if (!loaded || nextId > maxId) {
reserve();
}
long id = nextId;
nextId += step;
return id;
}
/**
* 预占下一个号段:区间是 (oldMax, newMax],区间内相邻 ID 的间隔为步长。
*/
private void reserve() 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, biz);
ps.setInt(2, instanceId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("号段记录不存在: biz=" + biz);
}
oldMax = rs.getLong("max_id");
currentStep = rs.getLong("step");
}
}
long newMax = oldMax + currentStep * batchSize;
try (PreparedStatement ps = conn.prepareStatement(UPDATE_SQL)) {
ps.setLong(1, newMax);
ps.setString(2, biz);
ps.setInt(3, instanceId);
ps.executeUpdate();
}
conn.commit();
this.maxId = newMax;
this.nextId = oldMax + currentStep;
this.step = currentStep;
this.loaded = true;
} catch (SQLException e) {
conn.rollback();
throw e;
}
}
}
}
批次大小和空洞的取舍:
| 单实例 QPS | 批大小 | 取号频率 | 实例重启最多浪费 |
|---|---|---|---|
| 1,000 | 1,000 | 1 次/秒 | 1,000 个 ID |
| 10,000 | 1,000 | 10 次/秒 | 1,000 个 ID |
| 100,000 | 10,000 | 10 次/秒 | 10,000 个 ID |
预占靠那一行记录的 FOR UPDATE 行锁串行化,所以同一实例的并发线程不会预占到重叠区间;跨实例由步长天然错开。代价是实例异常退出时,本地没发完的号段全部作废,形成空洞——空洞只影响 ID 连续性,不影响唯一性。
✅ 适合使用
1. 中型系统
- 需要一定高可用
- QPS < 10,000
- 多数据库实例
2. 数据库集群
- 已有数据库集群
- 需要利用现有集群
- 不想引入新组件
❌ 不适合使用
1. 高并发系统
- QPS > 100,000
- 需要极低延迟
- 数据库压力大
2. 跨机房系统
- 跨机房部署
- 网络延迟高
- 数据同步复杂
集群模式评价
| 维度 | 评分 | 说明 |
|---|---|---|
| 唯一性 | ⭐⭐⭐⭐⭐ | 通过配置保证 |
| 有序性 | ⭐⭐⭐⭐ | 大致有序 |
| 性能 | ⭐⭐⭐ | 比单库好 |
| 可用性 | ⭐⭐⭐⭐ | 多实例 |
| 扩展性 | ⭐⭐⭐ | 受限于数据库 |
使用建议
推荐使用:
✅ 已有数据库集群
✅ 中型规模系统
✅ 需要高可用
✅ 不想引入新组件
不推荐使用:
❌ 高并发系统
❌ 跨机房部署
❌ 极高性能要求
了解了集群模式后,我们学习步长策略优化。
👉 下一节:步长策略
发现内容有问题?
告诉我们哪里需要改进。
