单库自增

数据库自增是最简单的 ID 生成方案,让我们了解它的实现和原理。

基本原理

1. 自增主键

定义: 利用数据库的自增(AUTO_INCREMENT)功能生成唯一 ID。


实现:

数据设计要点

  • 核心是在 users 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
  • 关键字段包括 id、name、email、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-size20压测中并发 100 已达 50,000 QPS,超过 100 后延迟迅速上升
minimum-idle20与最大连接数相同,避免启动初期集中建连
connection-timeout3000ms拿不到连接要在 3 秒内失败,而不是无限等待
idle-timeout600000ms空闲 10 分钟回收,避开数据库侧的空闲断连
max-lifetime1800000ms30 分钟,小于数据库和中间件的连接回收时间

配置:

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. 并发插入时的性能

问题:

高并发时:
- 锁竞争严重
- 性能下降
- 可能死锁

解决:

  • 使用批量插入
  • 使用队列缓冲
  • 考虑其他方案

总结

单库自增评价

维度评分说明
唯一性⭐⭐⭐⭐⭐数据库保证
有序性⭐⭐⭐⭐⭐严格递增
性能⭐⭐性能瓶颈
可用性⭐⭐依赖数据库
扩展性⭐不适合分布式

使用建议

推荐使用:

✅ 小型系统
✅ 内部工具
✅ 测试环境
✅ 原型开发

不推荐使用:

❌ 大型系统
❌ 分布式系统
❌ 高并发系统
❌ 生产核心

下一步

了解了单库自增后,我们学习集群模式的自增方案。

👉 下一节:集群模式

发现内容有问题?

告诉我们哪里需要改进。