PAYATHON 2026

如何在 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 连接并独立提交,就是最直接、可靠的实现。

备注:内容仅供参考。