适用场景与现象

同一个接口在生产环境中偶发出现以下现象:有时写入没有立即提交,有时日期相差 8 小时,有时普通查询突然以更严格的事务隔离级别执行。重启应用后故障暂时消失,单独连接 MySQL 手工执行又无法复现。

这类问题常见于使用连接池的 Web 服务、异步任务和批处理程序。连接池归还的是可复用的物理连接,而 MySQL 的 autocommit、事务隔离级别、time_zonesql_mode、临时表和用户变量都属于会话状态。如果一段代码修改了状态,却没有在归还连接前恢复,下一次借到同一条连接的请求就会继承它。

本文以 MySQL 8.0 为例。先定位状态从哪里泄漏,再给出连接池边界上的复位、校验和测试方法。

为什么问题看起来没有规律

假设后台导入任务为了批量提交,执行了:

SET SESSION autocommit = 0;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET SESSION time_zone = '+00:00';

任务结束时只把连接放回池中,没有提交、回滚或恢复变量。之后的 API 请求是否异常,取决于它是否恰好借到这条物理连接,因此故障会随着并发量、池大小和连接调度变化。

不要仅凭应用配置认定会话状态正确。连接参数通常只在新建物理连接时执行;连接复用期间发生的修改,不会因为一次“归还再借出”自动恢复。也不要把重启连接池当成修复,它只是销毁了被污染的连接,泄漏代码仍会再次触发问题。

先采集同一条连接上的证据

在异常请求使用的连接上执行以下查询。所有语句必须通过应用已有的参数化数据库接口调用,不要把请求参数拼入 SQL。

SELECT
    CONNECTION_ID() AS connection_id,
    @@session.autocommit AS session_autocommit,
    @@session.transaction_isolation AS session_isolation,
    @@session.time_zone AS session_time_zone,
    @@session.sql_mode AS session_sql_mode,
    @@global.autocommit AS global_autocommit,
    @@global.transaction_isolation AS global_isolation,
    @@global.time_zone AS global_time_zone;

重点比较 session_* 与应用期望值,而不是机械要求它们都等于全局值:有些应用会明确使用 UTC 或 READ-COMMITTEDCONNECTION_ID() 用于把业务日志、慢查询和服务端线程关联起来;日志只记录连接 ID、变量名和非敏感状态,不能记录密码、令牌或完整 SQL 参数。

再检查连接当前是否仍有事务:

SELECT
    trx_mysql_thread_id,
    trx_started,
    trx_state,
    trx_rows_locked,
    trx_rows_modified
FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id = CONNECTION_ID();

如果第二条查询返回记录,说明当前连接仍处于 InnoDB 事务中。连接池在这种状态下复用连接,可能让锁、快照和未提交修改跨越业务请求边界。注意:autocommit=1 不代表一定没有显式事务,最终仍要结合事务表和驱动状态判断。

建立可重复的定位步骤

排查时为连接池的“借出”和“归还”两个边界增加受控日志,字段可以采用:

event=connection_checkout connection_id=2187 autocommit=0 isolation=SERIALIZABLE time_zone=+00:00
event=connection_checkin connection_id=2187 transaction_active=true reset_result=rollback

固定消息使用中文,例如“借出连接时发现会话状态偏离基线”;动态字段保持机器可读。不要在每条 SQL 上重复打印,避免热点日志放大。

然后按以下顺序缩小范围:

  1. 根据异常请求的 connection_id 查找上一次使用同一连接的任务。
  2. 搜索代码中的 SET SESSIONSET autocommit、隔离级别设置、临时表和用户变量。
  3. 检查异常、超时和任务取消路径是否仍会执行回滚与复位。
  4. 检查连接池是否只做了“存活探测”,却没有做事务回滚或会话清理。
  5. 用小连接池提高复现概率,例如池大小设为 1,让污染者与验证请求必然复用同一连接。

连接池大小设为 1 只适用于测试或隔离环境,不能直接作为生产排障手段。

修复一:事务必须在业务边界内闭合

最基本的规则是:开启事务的代码负责提交或回滚,并在所有异常路径上释放连接。下面的 Python DB-API 示例展示了明确的边界;连接池接口名称需按实际驱动调整。

from collections.abc import Callable
from typing import TypeVar


T = TypeVar("T")


def run_in_transaction(pool, operation: Callable[[object], T]) -> T:
    """在单个事务中执行操作,并确保异常路径完成回滚。"""
    connection = pool.get_connection()
    try:
        connection.start_transaction(isolation_level="READ COMMITTED")
        result = operation(connection)
        connection.commit()
        return result
    except BaseException:
        connection.rollback()
        raise
    finally:
        connection.close()

捕获 BaseException 是为了让取消和退出信号也先触发回滚,随后原样抛出;这里没有吞掉异常。生产代码还应给获取连接和 SQL 执行设置超时。若驱动的 close() 表示归还池而不是关闭套接字,要确认它是否在归还前自动回滚;不能依赖未经验证的默认行为。

修复二:在连接池边界复位会话状态

首选驱动或连接池提供的原生 reset 能力。MySQL 协议的会话重置可以清除事务、临时表、用户变量和大部分会话状态,同时保留物理连接;不同驱动对该能力的名称和覆盖范围不同,必须查阅所用版本文档并做测试。

如果连接池没有可靠的原生 reset,可以在归还连接时执行最小且明确的清理:

ROLLBACK;
SET SESSION autocommit = 1;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION time_zone = '+00:00';
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

这些值只是示例基线,必须与应用配置一致。ROLLBACK 应在其他 SET 之前执行;若连接已经断开或协议状态不可恢复,应丢弃该物理连接,不能把清理失败的连接重新放回池中。

手工复位还有两个边界:它无法可靠枚举并删除未知名称的临时表,也容易遗漏未来新增的会话变量。因此更稳妥的设计是禁止业务代码随意修改连接级状态;确实需要特殊隔离级别或时区时,使用独立连接池,或通过驱动提供的单事务选项设置,并在测试中验证归还后的状态。

用自动化测试锁住连接池行为

测试必须使用真实 MySQL 测试实例,因为内存数据库或模拟对象不会复现 MySQL 会话语义。将连接池大小设为 1,先主动污染连接,再归还并重新借出:

def test_pool_resets_session_state(mysql_pool) -> None:
    first = mysql_pool.get_connection()
    try:
        cursor = first.cursor()
        cursor.execute("SET SESSION autocommit = 0")
        cursor.execute(
            "SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE"
        )
        cursor.execute("SET SESSION time_zone = '+09:00'")
        cursor.execute("CREATE TEMPORARY TABLE leaked_state (id INT)")
    finally:
        first.close()

    second = mysql_pool.get_connection()
    try:
        cursor = second.cursor()
        cursor.execute(
            "SELECT @@session.autocommit, "
            "@@session.transaction_isolation, @@session.time_zone"
        )
        assert cursor.fetchone() == (1, "READ-COMMITTED", "+00:00")

        cursor.execute("SHOW TABLES LIKE 'leaked_state'")
        assert cursor.fetchone() is None
    finally:
        second.close()

这个测试同时验证事务配置和临时表是否被清理。若实际基线不是示例值,应从测试配置读取期望值。还应增加“业务函数抛异常”“数据库执行超时”和“任务取消”三类失败路径,确认每种情况下连接都被回滚,清理失败时被池淘汰。

上线与监控注意事项

修改连接池复位策略后,先在少量实例灰度。关注数据库新建连接速率、连接获取等待时间、事务持续时间和清理失败数。若复位实现会频繁销毁连接,可能引发连接风暴;这通常说明清理失败或驱动能力判断有误,不应简单扩大池容量掩盖问题。

建议增加以下低基数指标:

  • db_pool_session_drift_total{variable}:借出时发现变量偏离基线的次数。
  • db_pool_reset_failure_total{reason}:复位失败并淘汰连接的次数。
  • db_pool_active_transaction_on_checkin_total:归还时仍存在事务的次数。
  • db_pool_checkout_duration_seconds:获取连接的等待时间分布。

告警应基于持续偏离和失败率,不能把偶发网络断连与会话污染混为一谈。短期止血可以滚动重建连接池,但必须同时修复状态修改点和归还边界。

总结

连接池问题的关键不是“连接是否可用”,而是“连接是否仍符合应用基线”。遇到重启后消失、单连接无法复现、同一接口行为漂移的故障,应记录 CONNECTION_ID(),比较会话变量,并核对连接归还时是否仍有事务。

最终治理需要三层防线:业务代码闭合事务,连接池在归还时可靠复位或淘汰连接,自动化测试用单连接强制复现状态泄漏。这样才能避免一个请求对数据库会话的修改悄悄影响下一个请求。

参考资料