如何在 MySQL Transaction 外立即写入日志?
结论
MySQL 无法在同一个连接、同一个事务中单独提交某条 INSERT。只要日志通过当前事务的连接写入,执行 ROLLBACK 时,日志也会被回滚。SAVEPOINT 同样解决不了这个问题。
可靠的方案是使用第二个数据库连接写日志,并开启 autocommit,或者在这个连接上显式执行 COMMIT。MySQL 的事务归属于连接,因此第二个连接已经提交的日志不会受到主事务回滚的影响。
推荐实现:使用独立连接
主连接负责账户创建事务:
-- Connection A
START TRANSACTION;
INSERT INTO users (email, status)
VALUES ('user@example.com', 'pending');
INSERT INTO account_settings (user_id, setting_key, setting_value)
VALUES (123, 'timezone', 'Asia/Shanghai');
-- 调用支付流程
支付尝试结束后,通过另一个连接写入日志:
-- Connection B,autocommit = 1
INSERT INTO billing_attempt_log (
request_id,
account_reference,
result,
error_code,
created_at
) VALUES (
'req-8f42a1',
'signup-123',
'declined',
'card_declined',
CURRENT_TIMESTAMP
);
Connection B 开启了自动提交,这条日志会立即持久化。之后即使主连接回滚,日志也不会受影响:
-- Connection A
ROLLBACK;
支付成功时,主连接正常提交:
-- Connection A
COMMIT;
如果不能确定第二个连接是否开启了自动提交,可以显式管理事务:
-- Connection B
START TRANSACTION;
INSERT INTO billing_attempt_log (
request_id,
account_reference,
result,
created_at
) VALUES (
'req-8f42a1',
'signup-123',
'success',
CURRENT_TIMESTAMP
);
COMMIT;
应用层伪代码可以这样写:
mainConnection.beginTransaction()
try:
createAccount(mainConnection)
configureAccount(mainConnection)
billingResult = chargeCard()
logConnection.execute(
"INSERT INTO billing_attempt_log (...) VALUES (...)",
billingResult
)
if billingResult.success:
mainConnection.commit()
else:
mainConnection.rollback()
catch error:
mainConnection.rollback()
throw error
这里的 mainConnection 和 logConnection 必须对应两个真正独立的数据库会话,不能只是同一个连接对象的两个引用。
为什么同一连接做不到
MySQL 的事务边界作用于整个会话,而不是某条语句或某张表。例如,在事务中执行:
START TRANSACTION;
INSERT INTO users (...) VALUES (...);
INSERT INTO billing_attempt_log (...) VALUES (...);
ROLLBACK;
这两个 INSERT 都会被回滚。MySQL 没有通用的”自主事务”机制,无法让存储过程、触发器或某条语句脱离当前事务单独提交。
下面这些方法也无法解决问题:
SAVEPOINT:只能控制局部回滚,不能提前提交日志。- 触发器:仍然运行在触发它的事务中。
- 存储过程:默认使用调用连接的事务上下文。
- 在当前连接中先执行
COMMIT,再写日志:账户创建操作也会一并提交,原有的原子性会被破坏。 - 临时设置
autocommit = 1:切换自动提交状态可能导致当前事务被提交,不能用来隔离单条日志写入。
日志表的设计限制
独立连接无法看到主事务中尚未提交的数据,因此日志表不能依赖主事务里刚创建的账户记录。
例如,不要让日志表通过外键指向 users.id:
FOREIGN KEY (user_id) REFERENCES users(id)
当 Connection A 中的用户记录尚未提交时,Connection B 无法可靠地插入这条关联记录。更合适的做法是使用独立生成且不需要外键验证的关联标识:
CREATE TABLE billing_attempt_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
account_reference VARCHAR(64) NULL,
result VARCHAR(32) NOT NULL,
error_code VARCHAR(64) NULL,
message VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_billing_request_id (request_id)
) ENGINE=InnoDB;
request_id 最好由应用在事务开始前生成。即使账户创建最终回滚,仍可用它追踪完整的支付尝试。唯一索引还能避免重试时重复写入。是否允许同一个请求对应多次尝试,则应根据业务规则调整。
不建议依靠非事务表
理论上,可以把日志表设置为 MyISAM 等非事务存储引擎。对这类表的写入不会被 ROLLBACK 撤销:
CREATE TABLE billing_attempt_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
message VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=MyISAM;
但这种方案通常得不偿失:
- 日志一旦写入便无法回滚,即使之后发现内容有误;
- 缺少 InnoDB 提供的事务和崩溃恢复能力;
- 混用事务表与非事务表,容易在维护、备份和复制时产生意外;
MyISAM使用表级锁,并发能力通常不如 InnoDB。
更稳妥的方案仍是使用 InnoDB 日志表,并通过独立连接写入和提交。
还要注意支付流程本身
在数据库事务未结束时调用外部支付服务,可能导致事务长时间持有锁。另一个问题更难处理:支付已经成功,但随后数据库 COMMIT 失败。仅靠数据库事务,无法让支付系统与 MySQL 实现真正的原子性。
实际设计中至少应考虑以下事项:
- 为每次支付请求生成幂等的
request_id; - 记录
pending、success、declined和unknown等状态; - 支持使用支付平台的交易号进行对账;
- 为支付成功但账户提交失败的情况准备补偿机制或人工处理流程;
- 为第二个连接设置合理的连接超时和写入超时,避免日志写入失败后长时间阻塞注册事务。
如果目标只是避免支付日志随账户事务一起回滚,那么使用第二个 MySQL 连接并独立提交,就是最直接、可靠的实现。
备注:内容仅供参考。
