MrRobot5 生也有涯,知也无涯

数据层中间件使用二三事


近期在运维和改造旧系统的过程中,遇到了几个Mybatis 缓存、Spring 事务管理、数据库连接池影响事务隔离级别的问题。

特此记录问题的场景和主要原因,方便后续避坑和查阅。

Mybatis 一级缓存

①问题场景

使用 Mybatis 从数据库中查询的数据,再次查询发现前后两次查询结果不一致。

问题场景的代码示例:

BlogMapper bMapper = session.getMapper(BlogMapper.class);
Blog blog = bMapper.selectBlog(2);
System.out.println(blog.getName());
blog.setName("changed");
// 再次查询相同的数据时,MyBatis 会直接从缓存中获取结果,而不再执行 SQL 语句。
Blog blog2 = bMapper.selectBlog(2);
// 打印结果:"changed"。 blog 和 blog2 为同一对象。🎈
System.out.println(blog2.getName());

②分析过程

由于业务逻辑比较复杂,第一次查询的结果,在其他方法中对属性重新赋值。导致的现象比较奇怪。

通过开启 debug日志,打印 Mybatis SQL, 第二次查询没有发起 SQL 请求(再次执行相同的 SQL 语句时,MyBatis 会直接从缓存中获取结果,而不再发送 SQL 请求到数据库)。

Mybatis 使用了一级缓存。两次查询结果是同一对象。

/**
 * 一级缓存是 MyBatis 默认开启的缓存机制,它是基于 SqlSession 级别的缓存。
 * @see BaseExecutor#query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql)
 */
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    // By default, flushCacheRequired is false for select statements
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    try {
        // 相同 SQL (CacheKey 一样)再次查询,直接从缓存中获取结果。
        list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
        if (list != null) {
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            // 发送 SQL 请求到数据库
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    }
    return list;
}

③相关知识

  • MyBatis 一级缓存(Local Cache)是 MyBatis 默认开启的缓存机制,它是基于 SqlSession 级别的缓存。

  • 缓存失效场景:执行 SqlSession 的增删改操作(如 insert, update, delete),会清空一级缓存。

Spring 事务嵌套

①问题场景

为了解决大事务更新过程中,多事务间数据不可见的问题。

在后置处理方法中开启新事务,调整事务隔离级别为 READ_UNCOMMITTED。方便汇总计算逻辑可以及时共享数据。

带来的问题是: 有一定概率抛出异常 CannotAcquireLockException

Error updating database. Cause: java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction

②分析过程

开启新事务,用的是 Spring事务管理 REQUIRES_NEW 传播行为。

将当前事务 A 挂起,创建一个新的事务 B。

事务 A 中发起某条数据的update, 尚未提交。事务 B 同样发起这条数据的 update, 导致超时锁等待异常。

参考数据库的锁机制: Insight h2database 更新、读写锁以及事务原理

③总结

  • 这个问题是个典型的资源竞争 (Resource Contention)/死锁 (Deadlock) 问题。

  • 使用Spring 事务嵌套,需要特别注意更新数据的边界。尽量有充分的测试和验证,避免线上事故。

DBCP 事务隔离级别失效

①问题场景

在Spring 事务管理中,通过配置 Isolation.READ_UNCOMMITTED ,修改当前会话/数据库连接的事务隔离级别。

在测试验证中,其他事务更新过程中的数据,当前事务中 select 结果中没有。

也就是说,事务隔离级别并没有生效。

②分析过程

排查思路:使用 JdbcTemplate 以及原生 Jdbc API 开启事务隔离级别,把问题范围缩小并定位到数据库连接池组件(DBCP)上。

/**
 * 验证数据库连接的有效性
 * @param sql 如果sql参数是null或为空字符串,代码会调用连接的isValid(timeout)方法来检查连接是否有效。
 *            如果sql参数不为null,代码会执行这条SQL作为一个查询,并检查结果集(ResultSet)是否至少包含一行。
 * @see org.apache.commons.dbcp2.PoolableConnectionFactory#validateConnection
 * @see org.apache.commons.dbcp2.PoolableConnection#validate
 */
public void validate(final String sql, int timeout) throws SQLException {
    if (sql == null || sql.length() == 0) {
        // java.sql.Connection.isValid
        if (!isValid(timeout)) {
            throw new SQLException("isValid() returned false");
        }
        return;
    }

    if (!sql.equals(lastValidationSql)) {
        lastValidationSql = sql;
        // 此处创建了prepareStatement, 并缓存到当前 connection ❌
        validationPreparedStatement = getInnermostDelegateInternal().prepareStatement(sql);
    }
}

👉DBCP validationQuery 事务隔离级别失效 Example. TransactionIsolationExample

Spring 事务开启前、数据库连接池获取 Connection 之前,由于DBCP 已经创建了 prepareStatement, 导致后续再次设置事务隔离级别失效。

③ SQL 设置事务隔离级别

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

START TRANSACTION;

-- 事务 B 可以立即读取到事务 A 未提交的数据
SELECT finance_status FROM tb_some WHERE id = 11091;

Similar Posts

Content