性能瓶颈
数据库自增 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,000 | 2ms | 低 |
| 集群模式 | 100,000 | 1ms | 中 |
| 批量获取 | 500,000 | 0.1ms | 中 |
| 双 Buffer | 1,000,000 | 0.01ms | 高 |
| Redis 缓存 | 500,000 | 0.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,000 | 1,000 | 1 次/秒 | 1,000 个 ID |
| 10,000 | 1,000 | 10 次/秒 | 1,000 个 ID |
| 100,000 | 10,000 | 10 次/秒 | 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 方案
发现内容有问题?
告诉我们哪里需要改进。
