集群模式

单库自增无法满足分布式系统的需求,让我们学习如何在数据库集群中生成唯一 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,0001,0001 次/秒1,000 个 ID
10,0001,00010 次/秒1,000 个 ID
100,00010,00010 次/秒10,000 个 ID

预占靠那一行记录的 FOR UPDATE 行锁串行化,所以同一实例的并发线程不会预占到重叠区间;跨实例由步长天然错开。代价是实例异常退出时,本地没发完的号段全部作废,形成空洞——空洞只影响 ID 连续性,不影响唯一性。


适用场景

✅ 适合使用

1. 中型系统

- 需要一定高可用
- QPS < 10,000
- 多数据库实例

2. 数据库集群

- 已有数据库集群
- 需要利用现有集群
- 不想引入新组件

❌ 不适合使用

1. 高并发系统

- QPS > 100,000
- 需要极低延迟
- 数据库压力大

2. 跨机房系统

- 跨机房部署
- 网络延迟高
- 数据同步复杂

总结

集群模式评价

维度评分说明
唯一性⭐⭐⭐⭐⭐通过配置保证
有序性⭐⭐⭐⭐大致有序
性能⭐⭐⭐比单库好
可用性⭐⭐⭐⭐多实例
扩展性⭐⭐⭐受限于数据库

使用建议

推荐使用:

✅ 已有数据库集群
✅ 中型规模系统
✅ 需要高可用
✅ 不想引入新组件

不推荐使用:

❌ 高并发系统
❌ 跨机房部署
❌ 极高性能要求

下一步

了解了集群模式后,我们学习步长策略优化。

👉 下一节:步长策略

发现内容有问题?

告诉我们哪里需要改进。