单库自增
数据库自增是最简单的 ID 生成方案,让我们了解它的实现和原理。
1. 自增主键
定义: 利用数据库的自增(AUTO_INCREMENT)功能生成唯一 ID。
实现:
数据设计要点
- 核心是在
users里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
- 关键字段包括
id、name、created_at、INSERT,它们决定后续查询和管理能力。
Java 代码:
1. 生成流程
应用程序
↓
INSERT INTO users (name, email) VALUES (?, ?)
↓
数据库
↓
检查当前最大 ID
↓
ID = MAX(id) + 1
↓
插入数据
↓
返回生成的 ID
↓
应用程序
2. 事务保证
自增 ID 是在业务写入的同一条 INSERT 里分配的,所以 ID 的分配天然处在业务事务里。事务在这里保证两件事:ID 不会重复,ID 和业务数据不会「一个成功一个失败」。
唯一性:
- 自增计数器由数据库维护,分配动作在同一把内部锁下完成,并发插入拿不到同一个值
- 主键约束是最后一道防线,即使计数器被外部改动,重复值也会被直接拒绝
原子性:
- ID 分配和业务行写入在同一个事务内,提交则一起提交,回滚则一起回滚
- 回滚不会把 ID 还回去,计数器只增不减,所以回滚之后 ID 会出现空洞
回滚行为:
事务 A:INSERT → 拿到 ID 100
事务 A:ROLLBACK
事务 B:INSERT → 拿到 ID 101
ID 100 作废,计数器不回退
并发下的保证:
InnoDB 用 AUTO-INC 锁保证并发插入时分配的 ID 不重复,锁的粒度由 innodb_autoinc_lock_mode 决定:
0 = 传统模式:取号时加表级 AUTO-INC 锁,最保守
1 = 连续模式:普通 INSERT 用轻量级互斥量,批量插入才加表级锁(默认)
2 = 交错模式:取号不再加锁,批量插入提前分配一段区间,并发最好
Java 实现:
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name, email) VALUES (?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, name);
ps.setString(2, email);
ps.executeUpdate();
long userId;
try (ResultSet keys = ps.getGeneratedKeys()) {
userId = keys.next() ? keys.getLong(1) : 0L;
}
conn.commit();
return userId;
} catch (SQLException e) {
conn.rollback();
throw e;
}
}
回滚后 ID 不回收这件事,业务上要提前接受:ID 的作用是标识而不是计数,删除一行也不应该让计数器退回去。
优点
1. 实现简单 ⭐⭐⭐⭐⭐
✅ 无需额外代码
✅ 数据库原生支持
✅ 配置简单
✅ 开发效率高
2. 唯一性保证 ⭐⭐⭐⭐⭐
✅ 数据库保证唯一性
✅ 事务保证原子性
✅ 不会重复
✅ 理论和实践都保证
3. 严格递增 ⭐⭐⭐⭐⭐
✅ 严格按顺序递增
✅ 便于排序
✅ 便于范围查询
✅ 便于分页
4. 存储优化 ⭐⭐⭐⭐
✅ 使用 BIGINT(8字节)
✅ 存储空间小
✅ 索引效率高
✅ 查询性能好
缺点
1. 性能瓶颈 ⭐⭐⭐⭐⭐
❌ 需要数据库写入
❌ 网络往返时间
❌ 数据库压力
❌ QPS 受限
2. 依赖数据库 ⭐⭐⭐⭐⭐
❌ 数据库故障无法生成
❌ 单点故障
❌ 不适合分布式
❌ 扩展性差
3. 不连续 ⭐⭐⭐
❌ 删除数据后 ID 不连续
❌ 回滚事务 ID 不连续
❌ 批量插入可能有跳跃
4. 可预测性 ⭐⭐⭐
❌ 容易被推测
❌ 暴露业务量
❌ 安全性问题
1. QPS 测试
测试环境:
数据库:MySQL 8.0
硬件:4核CPU,16GB内存
表结构:简单的自增表
测试结果:
单线程:5,000 QPS
多线程:50,000 QPS
连接池满:无法提升
2. 延迟测试
测试结果:
本地数据库:1-5ms
远程数据库:5-20ms
高峰时段:50-100ms
3. 压力测试
结果:
并发 100:正常
并发 500:延迟增加
并发 1000:连接池耗尽
并发 5000:数据库宕机
✅ 适合使用
1. 小型系统
- 用户量 < 10万
- QPS < 1,000
- 单机部署
2. 内部系统
- 不需要高可用
- 不需要高性能
- 简单即可
3. 测试环境
- 快速开发
- 简单测试
- 不需要分布式
❌ 不适合使用
1. 大型系统
- 用户量 > 100万
- QPS > 10,000
- 需要高可用
2. 分布式系统
- 多服务节点
- 多数据库实例
- 需要水平扩展
3. 高并发系统
- 秒杀场景
- 高并发写入
- 低延迟要求
1. 使用连接池
自增 ID 的每一次分配都要占用一条数据库连接,并发上来之后,连接池就是整条链路上最先见底的地方。压测数据很直接:并发 100 时 QPS 还能到 50,000,连接池一满,后面的请求就开始排队。
连接池的关键不是「越大越好」,而是「够用且不超卖」:
| 参数 | 取值 | 依据 |
|---|---|---|
| maximum-pool-size | 20 | 压测中并发 100 已达 50,000 QPS,超过 100 后延迟迅速上升 |
| minimum-idle | 20 | 与最大连接数相同,避免启动初期集中建连 |
| connection-timeout | 3000ms | 拿不到连接要在 3 秒内失败,而不是无限等待 |
| idle-timeout | 600000ms | 空闲 10 分钟回收,避开数据库侧的空闲断连 |
| max-lifetime | 1800000ms | 30 分钟,小于数据库和中间件的连接回收时间 |
配置:
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai
hikari:
pool-name: id-generator-pool
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout 尤其重要:拿不到连接时快速失败、让上游重试,远好过请求堆在池子里等到超时——后者会把「数据库慢」放大成「整个服务慢」。
2. 批量插入
一次插一行的瓶颈在于「一次往返只写一行」:网络往返、SQL 解析、事务提交的开销都被放大 N 倍。批量插入把这些成本摊薄到多行上。
Java 实现:
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name, email) VALUES (?, ?)")) {
conn.setAutoCommit(false);
int size = users.size();
for (int i = 0; i < size; i++) {
User user = users.get(i);
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.addBatch();
// 每 500 行提交一次,避免单个事务过大
if ((i + 1) % 500 == 0) {
ps.executeBatch();
conn.commit();
}
}
ps.executeBatch();
conn.commit();
}
批量插入要配合两处配置,否则省不下来:
- 连接串加上
rewriteBatchedStatements=true,MySQL 驱动才会把批处理重写成一条多值 INSERT,而不是仍然逐条发出去。 - 每批的行数要控制住。一批 500 行是常见取值:再大,单条 SQL 会超过
max_allowed_packet而失败;再小,提交事务的开销又变明显。
批量插入同样不保证 ID 连续:一批里只要有一行失败,整批回滚,这一段 ID 就全部作废。
3. 事务管理
事务边界要围着 ID 分配来划,而不是围着业务代码来划。原则是:能小就小,能不包就不包。
| 场景 | 建议事务范围 | 原因 |
|---|---|---|
| 只取 ID | 不开事务 | 单条 INSERT 自带隐式事务,手工开事务反而多一次往返 |
| 取 ID 并写业务表 | 只包含这两步 | ID 和业务数据必须同生共死 |
| 取 ID 后调远程服务 | 拆成两段 | 远程调用把事务拖长,连接被长时间占用,失败率也会被放大 |
| 批量插入 | 每 500 行一个事务 | 事务过大时 undo log 和 binlog 都会膨胀,锁持有时间变长 |
另外三条经验:
- 隔离级别用
READ COMMITTED就够。自增 ID 的分配不依赖行数据,MySQL 默认的REPEATABLE READ反而会带来间隙锁,把并发插入串行化。 - 不要在事务里做远程调用、读文件、发消息这类慢操作,它们会把连接占用时间拉长到整条链路上最慢的那一步。
- 回滚不会回收 ID。ID 的作用是标识,计数器只增不减,业务上要接受空洞的存在。
事务范围示例:
// 取 ID 和写业务表放在一起,保证一致
public long createOrder(Order order) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO orders (user_id, amount) VALUES (?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setLong(1, order.getUserId());
ps.setBigDecimal(2, order.getAmount());
ps.executeUpdate();
long orderId;
try (ResultSet keys = ps.getGeneratedKeys()) {
orderId = keys.next() ? keys.getLong(1) : 0L;
}
conn.commit();
return orderId;
} catch (SQLException e) {
conn.rollback();
throw e;
}
}
}
注意这里的顺序:必须先 commit 再把连接还回池子。归还连接和提交事务是两件事,连接回到池里时事务还没结束的话,下一个借用它的线程会踩到脏状态。
1. ID 回滚后不连续
问题:
插入 ID:1, 2, 3
回滚事务
插入 ID:4, 5
ID 不连续:1, 2, 3, 4, 5
解决:
- 接受不连续
- 使用单独的 ID 生成器
- 使用其他方案
2. 数据库重启后 ID 跳跃
问题:
重启前 ID:1000
重启后 ID:2000
ID 跳跃:1000 → 2000
原因:
- MySQL 使用缓存机制
- 重启后重新计算
解决:
- 接受跳跃
- 调整数据库配置
3. 并发插入时的性能
问题:
高并发时:
- 锁竞争严重
- 性能下降
- 可能死锁
解决:
- 使用批量插入
- 使用队列缓冲
- 考虑其他方案
单库自增评价
| 维度 | 评分 | 说明 |
|---|---|---|
| 唯一性 | ⭐⭐⭐⭐⭐ | 数据库保证 |
| 有序性 | ⭐⭐⭐⭐⭐ | 严格递增 |
| 性能 | ⭐⭐ | 性能瓶颈 |
| 可用性 | ⭐⭐ | 依赖数据库 |
| 扩展性 | ⭐ | 不适合分布式 |
使用建议
推荐使用:
✅ 小型系统
✅ 内部工具
✅ 测试环境
✅ 原型开发
不推荐使用:
❌ 大型系统
❌ 分布式系统
❌ 高并发系统
❌ 生产核心
了解了单库自增后,我们学习集群模式的自增方案。
👉 下一节:集群模式
发现内容有问题?
告诉我们哪里需要改进。
