MySQL LAST_INSERT_ID 取号:同一连接与 JDBC 生成键边界
新增接口刚插入一行,再查询 LAST_INSERT_ID() 却拿到零,偶尔又拿到另一条记录的编号。先别给取号逻辑加分布式锁,核对两条 SQL 是否真的使用同一个数据库连接。
MySQL 的 LAST_INSERT_ID 状态属于连接。其他连接的插入不会把当前连接的结果改掉,这让并发取号可以安全隔离;连接池如果把插入和读取拆到两个物理连接上,这个优点就被应用自己绕开了。
两个窗口就能看清隔离范围
实验固定 MySQL 8.4、InnoDB。先建立独立库和一张带业务标识的表:
CREATE DATABASE generated_id_lab;
USE generated_id_lab;
CREATE TABLE registration (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
request_no VARCHAR(40) NOT NULL,
display_name VARCHAR(80) NOT NULL,
UNIQUE KEY uk_registration_request (request_no)
) ENGINE=InnoDB;
打开两个新连接 A、B,分别记录 CONNECTION_ID()。在 A 插入:
INSERT INTO generated_id_lab.registration(request_no, display_name)
VALUES ('req-a', 'alpha');
SELECT CONNECTION_ID(), LAST_INSERT_ID() AS id_a;
到 B 直接读取:
SELECT CONNECTION_ID(), LAST_INSERT_ID() AS id_b_before;
新连接还没有生成过自增值时,通常得到零,而不是 A 刚生成的编号。再让 B 插入自己的一行:
INSERT INTO generated_id_lab.registration(request_no, display_name)
VALUES ('req-b', 'beta');
SELECT LAST_INSERT_ID() AS id_b_after;
回到 A 再查,仍是 A 的连接状态。它不会因为 B 后来插入就变成全表最大编号。因此,用 SELECT MAX(id) 替换 LAST_INSERT_ID 不但没有修好问题,反而失去了连接隔离保护,可能把别人的对象当成本次新增结果返回。
事务存在,不自动意味着调用共用连接
如果一个事务由同一连接管理,插入和读取自然可以处在同一个连接内。但应用中名叫“事务方法”的代码,仍可能调用独立数据源、开启新事务,或在异步线程里重新借连接。方法名和注解不能替代连接归属证据。
诊断时可在受控环境为这两条 SQL 记录连接编号、请求标识和数据库端点。不要把密码、整份敏感请求或连接串中的凭据写进日志。确认是否存在读写分离代理,也很关键:取号查询如果被路由到另一个后端,就已经不是插入连接的状态。
最直接的改法,是在执行 INSERT 的语句对象上读取驱动返回的生成键。以下 Java 17 辅助类面向已建立的 JDBC 连接,使用 MySQL Connector/J;事务提交和回滚由调用方决定。
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public final class RegistrationWriter {
private RegistrationWriter() {}
public static long insert(Connection connection,
String requestNo,
String displayName) throws SQLException {
String sql = "INSERT INTO registration(request_no, display_name) VALUES (?, ?)";
try (PreparedStatement statement = connection.prepareStatement(
sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, requestNo);
statement.setString(2, displayName);
if (statement.executeUpdate() != 1) {
throw new SQLException("Unexpected inserted row count");
}
try (ResultSet keys = statement.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Generated key was not returned");
}
return keys.getLong(1);
}
}
}
}
代码没有在内部关闭传入连接,也没有擅自提交事务。它只负责一次插入及其生成键读取,让取号与发起该写入的语句对象绑定。驱动版本、批处理重写和多行插入的键返回行为,仍应单独验证;不要把这个单行例子扩展成未经测试的批量映射规则。
失败以后,旧的连接状态尤其容易误导
在 A 已经成功插入之后,再尝试重复业务标识:
-- 预期失败:request_no 已存在。
INSERT INTO generated_id_lab.registration(request_no, display_name)
VALUES ('req-a', 'duplicate');
不要在捕获失败后,无条件调用 LAST_INSERT_ID 并把返回值当成这次写入的编号。失败语句之后的状态不应被用来证明新增成功;某些路径会保留旧值,错误后的返回语义也有明确限制。业务成功与否必须由写入结果先确认。
使用 INSERT IGNORE 时更要检查是否实际插入了行。被忽略的重复请求可能没有产生新的记录,连接却仍保存着先前成功操作的编号。返回一个看起来合法的正整数,反而让错误更难发现。
如果接口需要重复请求返回同一个对象,应按唯一的 request_no 查询已有记录,并核对请求语义是否一致。这属于幂等协议,不应该靠一个连接里的“上次编号”碰运气。
多行插入只返回第一个值,不能据此推算整批结果
INSERT INTO generated_id_lab.registration(request_no, display_name)
VALUES ('req-c', 'gamma'), ('req-d', 'delta');
SELECT LAST_INSERT_ID() AS first_generated;
LAST_INSERT_ID 对单条多行 INSERT 返回的是首个自动生成值,不是一串编号,也不是最后一行。自增步长、预留和执行路径都影响批量取号的假设。需要把每个导入项映射回实际主键时,保留业务唯一标识,并用实际返回结果或受控回查建立对应关系。
显式插入某个普通主键值,也不能被假定会刷新这个函数的自动生成状态。它不是“当前语句触及的任何主键”的通用返回接口。
实验做完可删独立库:
DROP DATABASE generated_id_lab;
取号问题的排查重点应该是连接和写入结果。只要把这两个边界守住,就不需要用全表最大值、延迟等待或额外互斥锁来猜本次插入的是哪一行。