Redis中的事务陷阱

前言

  源于文章https://mp.weixin.qq.com/s/hY8PTC651pu_5N5UtNh2Jw

现象

起因:
每天早上客服人员在后台创建客服事件时,都会创建失败。
当我们重启这个微服务后,后台就可以正常创建了客服事件了。
到第二天早上又会创建失败,又得重启这个微服务才行。

分析:
创建一个客服事件时,会用到 Redis 的递增操作来生成一个唯一的分布式 ID 作为事件 id。
而恰巧每天早上这个递增操作都会返回 null,进而导致后面的一系列逻辑出错,保存客服事件失败。
当重启微服务后,这个递增操作又正常了。

排查的方向:
Redis 的操作为什么会返回 null 了,以及为什么重启就又恢复正常了

 

排查

【1】推测一

根据重启后就恢复正常,推测应该是执行了大量的 job,大量 Redis 连接未释放,故再来执行 Redis 操作时,执行失败。
重启后,连接自动释放了。

【1.1】验证

但如果其他有使用到 Redis 的业务功能又是正常的,那么推测一的方向便可以排除。

【2】直接查看代码

直接看 redisTemplate 递增的方法 increment:

/**
 * Increment an integer value stored as string value under {@code key} by {@code delta}.
 *
 * @param key must not be {@literal null}.
 * @param delta
 * @return {@literal null} when used in pipeline / transaction.
 * @see <a href="https://redis.io/commands/incrby">Redis Documentation: INCRBY</a>
 */
@Nullable
Long increment(K key, long delta);

//官方注释已经说明什么情况下会返回 null:
//当在 pipeline(管道)中使用这个 increment 方法时会返回 null//当在 transaction(事务)中使用这个 increment 方法时会返回 null

【2.1】redis的事务说明

事务提供了一种将多个命令打包,然后一次性、有序地执行机制.
多个命令会被入列到事务队列中,然后按先进先出(FIFO)的顺序执行。
事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。(内容来自 Redis 设计与实现)

【3】既然事务有可能造成空值,那么在项目中存在事务的便是业务代码的事务和redis的事务两种

【4】推测二【业务代码的事务,但redis不开启事务】

【4.1】实验代码

@Transactional(rollbackFor = Exception.class)
@Override
public void test() {
    Long increment = redisTemplate.opsForValue().increment("count", 1);
    System.out.println(increment);
}

【4.2】结果说明

Postman 测试下,发现每发一次请求,count 都会递增 1,并没有返回 null。
到 Redis 中查看数据,count 的值也是递增后的值,也不是 null。
在 @Transactional 注解的方法里面执行 Redis 的操作并不会返回 null

 

【5】推测三【业务代码不加事务,但redis开启事务】

【5.1】实验代码【先将redis事务开启,setEnableTransactionSupport表示是否开启事务支持,默认不开启

public void test() {
    redisTemplate.setEnableTransactionSupport(true);  //开启支持事务
    redisTemplate.multi(); //启动事务
    redisTemplate.opsForValue().set("k1","2"); //将操作塞入事务队列
    redisTemplate.opsForValue().set("k2","3");
    redisTemplate.opsForValue().set("k3","4");
    redisTemplate.exec();  //执行事务
}

【5.2】逻辑为

 

 

 

【5.3】再执行redis操作

@Override
public void test() {
    Long increment = redisTemplate.opsForValue().increment("count", 1);
    System.out.println(increment);
}

【5.4】结果

用 Postman 调用这个接口后,正常返回自增后的结果,并不是返回 null。
说明在非 @Transactional 中执行 Redis 操作并没有受到 Redis 事务的影响。

 

【6】推测四【业务代码加事务,redis开启事务】

【6.1】添加 @Transactional 注解再次测试

@Transactional(rollbackFor = Exception.class)
@Override
public void test() {
    Long increment = redisTemplate.opsForValue().increment("count", 1);
    System.out.println(increment);
}

【6.2】结果说明

多次执行这个命令返回的结果都是 null,这不就正好重现了!
再看 Redis 中 count 的值,发现每执行一次 API 请求调用,都会递增 1,所以虽然命令返回的是 null,但最后 Redis 中存放的还是递增后的结果。

【6.3】原因排查

说明 RedisTemplete 开启了 Redis 事务支持后在 @Transactional 中执行的 Redis 命令也会被认为是在 Redis 事务中执行的要执行的递增命令会被放到队列中,不会立即返回执行后的结果,返回的是一个 null需要等待事务提交时,队列中的命令才会顺序执行,最后 Redis 数据库的键值才会递增

 

源码分析

【1】分析increment方法

@Override
public Double increment(K key, double delta) {

    byte[] rawKey = rawKey(key);
    return execute(connection -> connection.incrBy(rawKey, delta), true);
}

@Nullable
<T> T execute(RedisCallback<T> callback, boolean exposeConnection) {
    return template.execute(callback, exposeConnection);
}

@Nullable
public <T> T execute(RedisCallback<T> action, boolean exposeConnection) {
    return execute(action, exposeConnection, false);
}

【2】分析Redis执行命令的核心方法,execute 方法

@Nullable
public <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean pipeline) {

    Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");
    Assert.notNull(action, "Callback object must not be null");

    RedisConnectionFactory factory = getRequiredConnectionFactory();
    RedisConnection conn = RedisConnectionUtils.getConnection(factory, enableTransactionSupport);

    try {

        boolean existingConnection = TransactionSynchronizationManager.hasResource(factory);
        RedisConnection connToUse = preProcessConnection(conn, existingConnection);

        boolean pipelineStatus = connToUse.isPipelined();
        if (pipeline && !pipelineStatus) {
            connToUse.openPipeline();
        }

        RedisConnection connToExpose = (exposeConnection ? connToUse : createRedisConnectionProxy(connToUse));
        //从redis中获取值
        T result = action.doInRedis(connToExpose);

        // close pipeline
        if (pipeline && !pipelineStatus) {
            connToUse.closePipeline();
        }

        return postProcessResult(result, connToUse, existingConnection);
    } finally {
        //最后释放连接
        RedisConnectionUtils.releaseConnection(conn, factory, enableTransactionSupport);
    }
}

 

【2.1】分析如何获得连接

public static RedisConnection getConnection(RedisConnectionFactory factory, boolean transactionSupport) {
    return doGetConnection(factory, true, false, transactionSupport);
}

public static RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind, boolean transactionSupport) {

    Assert.notNull(factory, "No RedisConnectionFactory specified");

    RedisConnectionHolder conHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);

    if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
        conHolder.requested();
        if (!conHolder.hasConnection()) {
            log.debug("Fetching resumed Redis Connection from RedisConnectionFactory");
            conHolder.setConnection(fetchConnection(factory));
        }
        return conHolder.getRequiredConnection();
    }

    // Else we either got no holder or an empty thread-bound holder here.

    if (!allowCreate) {
        throw new IllegalArgumentException("No connection found and allowCreate = false");
    }

    log.debug("Fetching Redis Connection from RedisConnectionFactory");
    //在这里获取一个连接
    RedisConnection connection = fetchConnection(factory);

    //可以看出,除了开启redis事务的同时还要支持事务事件,就会进行绑定
    boolean bindSynchronization = TransactionSynchronizationManager.isActualTransactionActive() && transactionSupport;

    //由于bind默认是false,所以主要看bindSynchronization
    if (bind || bindSynchronization) {

        if (bindSynchronization && isActualNonReadonlyTransactionActive()) {
            connection = createConnectionSplittingProxy(connection, factory);
        }

        try {
            // Use same RedisConnection for further Redis actions within the transaction.
            // Thread-bound object will get removed by synchronization at transaction completion.
            RedisConnectionHolder holderToUse = conHolder;
            if (holderToUse == null) {
                holderToUse = new RedisConnectionHolder(connection);
            } else {
                holderToUse.setConnection(connection);
            }
            holderToUse.requested();

            // Consider callback-scope connection binding vs. transaction scope binding
            if (bindSynchronization) {
                //这个方法是如果加了@Transactional注解,帮你开启 multi
                potentiallyRegisterTransactionSynchronisation(holderToUse, factory);
            }

            if (holderToUse != conHolder) {
                //这里将代码绑定到当前线程,内部使用ThreadLocal实现
                TransactionSynchronizationManager.bindResource(factory, holderToUse);
            }
        } catch (RuntimeException ex) {
            // Unexpected exception from external delegation call -> close Connection and rethrow.
            releaseConnection(connection, factory);
            throw ex;
        }

        return connection;
    }

    return connection;
}

 

【2.1.1】分析核心的potentiallyRegisterTransactionSynchronisation方法【可以看出:开启 Redis 事务支持 + @Transactional 注解后,最后其实是标记了一个 Redis 事务块,后续的操作命令是在这个事务块中执行的。而Redis Multi 命令用于标记一个事务块的开始,事务块内的多条命令会按照先后顺序被放进一个队列当中,最后由 EXEC 命令原子性(atomic)地执行。】

private static void potentiallyRegisterTransactionSynchronisation(RedisConnectionHolder connHolder, final RedisConnectionFactory factory) {

    // Should go actually into RedisTransactionManager

    //判断是否添加了@Transactional 注解
    if (!connHolder.isTransactionActive()) {

        connHolder.setTransactionActive(true);
        connHolder.setSynchronizedWithTransaction(true);
        connHolder.requested();

        RedisConnection conn = connHolder.getRequiredConnection();
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        //非只读情况下默认是开启事务
        if (!readOnly) {
            conn.multi();
        }

        TransactionSynchronizationManager.registerSynchronization(new RedisTransactionSynchronizer(connHolder, conn, factory, readOnly));
    }
}
以上就是开启连接,并从redis中获取属性的过程,因为没有加@Transactional注解,所以并没有开启multi,命令也没有进入Queue中,可以直接拿到redis中的值
但是, 还是进行了将连接绑定到当前线程的操作。【这就是为什么@Transactional 注解 和 redisTemplate.setEnableTransactionSupport(true);  //开启支持事务 一起用会容易造成问题的原因】

 

【2.2】分析如何释放连接

@Deprecated
public static void releaseConnection(@Nullable RedisConnection conn, RedisConnectionFactory factory, boolean transactionSupport) {
    releaseConnection(conn, factory);
}

public static void releaseConnection(@Nullable RedisConnection conn, RedisConnectionFactory factory) {
    if (conn == null) {
        return;
    }

    RedisConnectionHolder conHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);
    if (conHolder != null) {

        if (conHolder.isTransactionActive()) {
            if (connectionEquals(conHolder, conn)) {
                if (log.isDebugEnabled()) {
                    log.debug("RedisConnection will be closed when transaction finished.");
                }

                // It's the transactional Connection: Don't close it.
                //对于事务的连接并没有关闭,而是采用将句柄置为null
                conHolder.released();
            }
            return;
        }

        // release transactional/read-only and non-transactional/non-bound connections.
        // transactional connections for read-only transactions get no synchronizer registered

        unbindConnection(factory);
        return;
    }
    //关闭连接
    doCloseConnection(conn);
}

private static void doCloseConnection(@Nullable RedisConnection connection) {

    if (connection == null) {
        return;
    }

    if (log.isDebugEnabled()) {
        log.debug("Closing Redis Connection.");
    }

    try {
        connection.close();
    } catch (DataAccessException ex) {
        log.debug("Could not close Redis Connection", ex);
    } catch (Throwable ex) {
        log.debug("Unexpected exception on closing Redis Connection", ex);
    }
}
可以看到针对打开事务支持的template,只是解绑了连接,根本没有做close的操作。
对于开启了事务的Template,由于已经绑定了线程中连接,所以这里是不会关闭的,只是做了解绑的操作。
只要template开启了事务支持,spring就认为只要使用这个template就会包含在事务当中,因为一个事务中的操作必须在同一个连接中完成,
所以在每次get
/set之后,template是不会关闭链接的,因为它不知道事务有没有结束。

【2.2.1】分析解除绑定的unbindConnection方法

public static void unbindConnection(RedisConnectionFactory factory) {

    RedisConnectionHolder conHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);

    if (conHolder == null) {
        return;
    }

    if (log.isDebugEnabled()) {
        log.debug("Unbinding Redis Connection.");
    }

    if (conHolder.isTransactionActive()) {
        if (log.isDebugEnabled()) {
            log.debug("Redis Connection will be closed when outer transaction finished.");
        }
    } else {

        RedisConnection connection = conHolder.getConnection();
        conHolder.released();

        if (!conHolder.isOpen()) {

            TransactionSynchronizationManager.unbindResourceIfPossible(factory);

            doCloseConnection(connection);
        }
    }
}

 

【2.2】分析TransactionSynchronizationManager的unbindResourceIfPossible方法

发现有TransactionSynchronizationManager.unbindResource(factory),这个方法的内部就是将资源释放。
redisTemplate开启了事务,在未标明@Transactional的方法内使用时,可以在redisTemplate操作redis之后立马调用该方法

 

@Nullable
public static Object unbindResourceIfPossible(Object key) {
    Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
    return doUnbindResource(actualKey);
}

@Nullable
private static Object doUnbindResource(Object actualKey) {
    Map<Object, Object> map = resources.get();
    if (map == null) {
        return null;
    }
    Object value = map.remove(actualKey);
    // Remove entire ThreadLocal if empty...
    if (map.isEmpty()) {
        resources.remove();
    }
    // Transparently suppress a ResourceHolder that was marked as void...
    if (value instanceof ResourceHolder && ((ResourceHolder) value).isVoid()) {
        value = null;
    }
    if (value != null && logger.isTraceEnabled()) {
        logger.trace("Removed value [" + value + "] for key [" + actualKey + "] from thread [" +
                Thread.currentThread().getName() + "]");
    }
    return value;
}

【3】那@Transactional注解是怎么释放连接的呢?

发现TransactionSynchronizationUtils.invokeAfterCompletion 这个方法
遍历得到每一个TransactionSynchronization,然后调用它的afterCompletion方法,TransactionSynchronization是一个接口,实现它的有很多,像jdbc数据源释放 什么的都是通过该方法一次性释放

看一下RedisTransactionSynchronizer 的 afterCompletion方法,发现在此方法内提交或回滚事务,并最终释放连接

 

解决方案

【1】方案一:每次 Redis 的事务操作完成后,关闭 Redis 事务支持,然后再执行 @Transactional 中的 Redis 命令。(有弊端)

【1.1】代码说明

【1.2】弊端说明:如果在执行 Redis 事务期间,在 @Transactional 注解的方法里面执行 Redis 命令,则还是会造成返回结果为 null

究其原由,还是因为存在并发问题,
存在一个redis连接之中在某个执行事务的阶段,
会有另一个被 @Transactional注解修饰的 Redis 命令也会被编入redis事务中。

 

 

 

【2】方案二:创建两个 StringRedisTemplate,一个专门用来执行 Redis 事务,一个用来执行普通的 Redis 命令。

【2.1】说明

先创建一个 RedisConfig 文件,自动装配两个 Bean。
一个 Bean 名为 stringRedisTemplate 代表不支持事务的,执行命令后立即返回实际的执行结果。
另外一个 Bean 名为 stringRedisTemplateTransaction,代表开启 Redis 事务支持的。

【2.2】分别注入

【2.3】分开使用

【2.3.1】Redis 事务的操作改写成不需要手动开启 Redis 事务支持了。用到的 StringRedisTemplate 是支持事务的那个实例。

public void test() {
    StringRedisTemplateTransaction.multi(); //启动事务
    StringRedisTemplateTransaction.opsForValue().set("k1","2"); //将操作塞入事务队列
    StringRedisTemplateTransaction.opsForValue().set("k2","3");
    StringRedisTemplateTransaction.opsForValue().set("k3","4");
    StringRedisTemplateTransaction.exec();  //执行事务
}

 

【2.3.2】在 Spring 的 @Tranactional 中执行的 Redis 命令如下所示,用到的 StringRedisTemplate 是不支持事务的那个实例。

@Transactional
public void test() {
    Long increment = StringRedisTemplate.opsForValue().increment("count", 1);
    System.out.println(increment);
}

 

posted @ 2023-01-03 23:47  忧愁的chafry  阅读(597)  评论(0编辑  收藏  举报