性能瓶颈

数据库自增 ID 方案虽然简单,但在高并发场景下会遇到明显的性能瓶颈。让我们分析这些瓶颈和解决方案。

瓶颈分析

1. 网络延迟

问题:

应用程序 → 数据库(网络往返)
往返时间(RTT):1-10ms

每次生成 ID 都需要网络调用

影响:

单次延迟:1-10ms
QPS 受限:约 100-1000 QPS
性能下降:明显

示例:


2. 数据库锁竞争

问题:

多个线程同时插入
竞争自增锁
串行化执行

影响:

并发增加 → 锁竞争增加 → 性能下降
100 并发:50,000 QPS
500 并发:10,000 QPS
1000 并发:1,000 QPS

3. 数据库写入压力

问题:

每个 ID 生成都需要写数据库
写入压力巨大
数据库成为瓶颈

影响:

10万 QPS = 每秒 10万次写入
单机 MySQL:约 5万 QPS
需要 2 台数据库实例

4. 连接池耗尽

问题:

高并发下连接池不够
请求等待排队
性能急剧下降

示例:


性能测试

1. 基准测试

测试环境:

数据库:MySQL 8.0
硬件:4核CPU,16GB内存
连接池:20
测试时间:60秒

测试结果:

并发 | QPS     | P50延迟 | P99延迟 | 错误率
-----|---------|---------|---------|--------
10   | 8,500   | 2ms     | 5ms     | 0%
50   | 50,000  | 1ms     | 10ms    | 0%
100  | 50,000  | 2ms     | 20ms    | 0%
500  | 10,000  | 50ms    | 200ms   | 5%
1000 | 1,000   | 500ms   | 1000ms  | 50%

2. 压力测试

结论:

最佳并发:50-100
最大 QPS:50,000
性能拐点:100 并发
崩溃点:1000 并发

优化方案

1. 批量获取

原理: 一次获取一批 ID,本地分配。


实现:


性能提升:

原来:每次请求都访问数据库
现在:1000 次请求才访问数据库一次
性能提升:1000x

2. 双 Buffer 机制

原理: 使用两个缓冲区,一个当前使用,一个预加载。


性能提升:

零等待:始终有可用 ID
高并发:支持超高并发
性能:接近本地生成

3. 缓存优化

原理: 使用 Redis 缓存 ID 段。


实现:


性能对比

不同方案对比

方案QPS延迟复杂度
单库自增50,0002ms低
集群模式100,0001ms中
批量获取500,0000.1ms中
双 Buffer1,000,0000.01ms高
Redis 缓存500,0000.1ms中

最佳实践

1. 使用批量获取

批量获取是这一层性价比最高的一招:把「每个 ID 一次数据库往返」换成「每批 ID 一次数据库往返」。应用向数据库预占一段 ID(默认 1000 个),在本地逐个发放,发完再预占下一段。

号段表按业务类型一行记录,max_id 是已经分配出去的最大值:

CREATE TABLE id_alloc (
    id   BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    biz  VARCHAR(64)     NOT NULL,
    max_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uk_biz (biz)
) ENGINE=InnoDB;
/**
 * 批量取号器:一次向数据库预占一段 ID,本地逐个发放。
 */
public class BatchIdAllocator {

    private static final int BATCH_SIZE = 1000;

    private static final String SELECT_SQL =
            "SELECT max_id FROM id_alloc WHERE biz = ? FOR UPDATE";
    private static final String UPDATE_SQL =
            "UPDATE id_alloc SET max_id = ? WHERE biz = ?";

    private final DataSource dataSource;
    private final String biz;

    /** 当前批次的右端点(含)。 */
    private long maxId;
    /** 当前批次的下一个待发 ID。 */
    private long nextId;
    /** 是否已经预占过批次。 */
    private boolean loaded;

    public BatchIdAllocator(DataSource dataSource, String biz) {
        this.dataSource = dataSource;
        this.biz = biz;
    }

    /** 取下一个 ID;本批用完时自动预占下一批。 */
    public synchronized long nextId() throws SQLException {
        if (!loaded || nextId > maxId) {
            reserve();
        }
        return nextId++;
    }

    /** 向数据库预占一批 ID,区间是 (oldMax, newMax]。 */
    private void reserve() throws SQLException {
        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);
            try {
                long oldMax;
                try (PreparedStatement ps = conn.prepareStatement(SELECT_SQL)) {
                    ps.setString(1, biz);
                    try (ResultSet rs = ps.executeQuery()) {
                        if (!rs.next()) {
                            throw new IllegalStateException("号段记录不存在: biz=" + biz);
                        }
                        oldMax = rs.getLong(1);
                    }
                }

                long newMax = oldMax + BATCH_SIZE;
                try (PreparedStatement ps = conn.prepareStatement(UPDATE_SQL)) {
                    ps.setLong(1, newMax);
                    ps.setString(2, biz);
                    ps.executeUpdate();
                }
                conn.commit();

                this.maxId = newMax;
                this.nextId = oldMax + 1;
                this.loaded = true;
            } catch (SQLException e) {
                conn.rollback();
                throw e;
            }
        }
    }
}

批大小和流量是配套的:单实例 1,000 QPS 用 1,000 的批,每秒才取一次号;单实例 100,000 QPS 还是用 1,000 的批,就要每秒取 100 次号,数据库照样被打满。

单实例 QPS批大小取号频率实例重启浪费
1,0001,0001 次/秒1,000 个 ID
10,0001,00010 次/秒1,000 个 ID
100,00010,00010 次/秒10,000 个 ID

批大小翻十倍,数据库写压力不变,但实例重启时浪费的 ID 也翻十倍。批大小要按「可接受的空洞量」来定,这是唯一的代价。


2. 使用双 Buffer

单 Buffer 的问题是:本地号段刚好用完的那一刻,下一个请求必须等这次数据库往返结束才能拿到新号段,这段等待会以延迟尖刺的形式出现在监控曲线上。步长越大尖刺越少,但一旦出现就是一次实实在在的卡顿。

双 Buffer 把号段分成「当前使用」和「预加载」两份:Buffer 1 还在发放时,后台线程已经替你去申请 Buffer 3;等 Buffer 1 见底,直接切到早已就位的 Buffer 2,切换只是一次内存里的指针替换。

   发放中                       预加载中
┌────────────────┐          ┌────────────────┐
│ Buffer 1       │          │ Buffer 2       │
│ [1001 - 2000]  │  正在发   │ [2001 - 3000]  │  正在申请
└────────────────┘          └────────────────┘
        ↓ 用完,切换
┌────────────────┐          ┌────────────────┐
│ Buffer 2       │          │ Buffer 3       │
│ [2001 - 3000]  │  正在发   │ [3001 - 4000]  │  正在申请
└────────────────┘          └────────────────┘

切换耗时从「一次数据库往返(约 100ms)」变成「一次指针替换(不到 1ms)」,等待被提前藏进了预加载阶段。

要真正把双 Buffer 跑起来,有三件事必须做对:

  • 同一时刻只允许一个预加载任务在跑,否则会重复申请、浪费号段。
  • 预加载失败要保留当前 Buffer 的剩余量并重试,不能让业务请求陪着一起失败。
  • 预加载线程的异常要单独告警,因为它不会体现在业务错误率上。

3. 使用缓存

号段还可以整体缓存在 Redis 里:数据库只负责「盖章」(记录号段走到哪了),Redis 负责「发号」(用 INCRBY 一次给出一段)。应用取号时打 Redis,只有在号段即将用完、或者需要重新初始化时才会碰到数据库。

SETNX id:order:max 0          -- 首次使用时把计数器初始化为 0
INCRBY id:order:max 1000      -- 预占 1000 个号,返回值就是这一段的右端点

对应到 Java 侧,取号只是一次 INCRBY:

/** 一次从 Redis 预占一段 ID,返回区间的左右端点。 */
public long[] allocate(int batchSize) {
    long end = jedis.incrby("id:order:max", batchSize);
    return new long[] { end - batchSize + 1, end };
}

用 Redis 缓存号段要特别注意三点:

  • 计数器不能设过期时间。过期之后重新计数会和历史号段重叠,唯一性直接被破坏。
  • 持久化至少要开 AOF(appendfsync everysec)。只靠 RDB 的话,Redis 重启后计数器会回退到快照里的旧值,同样会发出重复 ID。
  • Redis 和数据库是两个数据源,切换时以数据库里记录的号段进度为准重新初始化 Redis,不能各发各的。

总结

数据库自增方案总结

维度评分说明
唯一性⭐⭐⭐⭐⭐数据库保证
有序性⭐⭐⭐⭐⭐严格递增
性能⭐⭐需要优化
可用性⭐⭐⭐需要集群
扩展性⭐⭐受限

使用建议

推荐使用:

✅ 小型系统
✅ 内部系统
✅ 已有数据库集群
✅ 不想引入新组件

不推荐使用:

❌ 高并发系统
❌ 超大规模系统
❌ 需要极高性能

优化方向

数据库自增 → 批量获取 → 双 Buffer → 其他方案

下一步

了解了数据库自增的性能瓶颈后,我们学习 Redis 方案。

👉 下一章:Redis 方案

发现内容有问题?

告诉我们哪里需要改进。