返回
Featured image of post Redis - Spring Data Redis 中的 Transaction 与 Pipeline

Redis - Spring Data Redis 中的 Transaction 与 Pipeline

附加 Spring Redis 中 .lua 的使用. Pipeline 在 Spring Redis 程序优化中大有用武之地.

Redis 提供事务(Transaction)和管道(Pipeline),两个概念都不复杂,可以对比来看,管道可以广泛用于提升 Redis 读写效率。

Redis Transactions

Redis 原本通过 multi execdiscard 指令执行事务。RedisTemplate<> 本身提供三个同名方法来支持事务,但是 RedisTemplate 本身封装程度高,不能保证这种事务定义下的所有操作在同一次 Redis 连接中被执行

因此,作为一种封装度更低的替代,Spring Data Redis 提供 SessionCallback 来确保多个操作在同一次 Redis 链接中进行。

//execute a transaction
List<Object> txResults = redisTemplate.execute(new SessionCallback<List<Object>>() {
  public List<Object> execute(RedisOperations operations) throws DataAccessException {
    operations.multi();
    operations.opsForSet().add("key", "value1");

    // This will contain the results of all operations in the transaction
    return operations.exec();
  }
});
System.out.println("Number of items added to set: " + txResults.get(0));

SessionCallbackexecute 返回值仍会经过 RedisTemplate 的反序列化操作 ,然后作为 RedisTemplateexecute 结果返回。

1.1 后对于连接返回值的转换与否发生了变化

As of version 1.1, an important change has been made to the exec methods of RedisConnection and RedisTemplate. Previously, these methods returned the results of transactions directly from the connectors. This means that the data types often differed from those returned from the methods of RedisConnection. For example, zAdd returns a boolean indicating whether the element has been added to the sorted set. Most connectors return this value as a long, and Spring Data Redis performs the conversion. Another common difference is that most connectors return a status reply (usually the string, OK) for operations such as set. These replies are typically discarded by Spring Data Redis. Prior to 1.1, these conversions were not performed on the results of exec. Also, results were not deserialized in RedisTemplate, so they often included raw byte arrays. If this change breaks your application, set convertPipelineAndTxResults to false on your RedisConnectionFactory to disable this behavior.

@Transactional

RedisTemplate 默认不能加 @Transactional 标签,但可以通过 template.setEnableTransactionSupport(true) 来开启。

Enabling transaction support binds RedisConnection to the current transaction backed by a ThreadLocal. If the transaction finishes without errors, the Redis transaction gets commited with EXEC, otherwise rolled back with DISCARD.

但这样做存在一些限制

// must be performed on thread-bound connection
template.opsForValue().set("thing1", "thing2");

// read operation must be run on a free (not transaction-aware) connection
template.keys("*");

// returns null as values set within a transaction are not visible
template.opsForValue().get("thing1");

Redis Pipelining

Redis 管道允许一次性发送多条指令而不单独等待每条的回复(即不用等待 RTT),所有恢复将在最后一次性读取。这样做会提升指令发送速度,尤其在向一个 List 中添加大量元素时。

如果完全不关注返回结果,可以使用 RedisTemplateexecute 方法,传递 true 的 pipeline 参数。executePipelined 方法以 RedisCallbackSessionCallback 为输入,在一个 pipeline 中完成运行并返回所有结果。

以下是一个通过 pipeline 从队列右端 pop 出一堆元素的方法:

List<Object> results = stringRedisTemplate.executePipelined(
  new RedisCallback<Object>() {
    public Object doInRedis(RedisConnection connection) throws DataAccessException {
      StringRedisConnection stringRedisConn = (StringRedisConnection)connection;
      for(int i=0; i< batchSize; i++) {
        stringRedisConn.rPop("myqueue");
      }
    return null;
  }
});

两者区别

首先,Pipline 和 Transaction 是完全不同的两种机制,互相不能够替代——

  1. Pipeline 本质是客户端对指令进行打包发送的行为,服务端是透明的;Transaction 本质是服务端执行指令时进行的打包,由客户端指令指挥

    因此,Pipeline 最终要的作用是减少客户端等待时间,从而提升客户端程序性能,不能保证原子性(服务端只会看作一系列普通指令,当然不会保证原子性的)。而 Transaction 因为使用 MULTI/EXEC 事务机制,虽然从 SQL 事务 ACID 角度来看不满足原子性,但如果全部指令都能被正确执行,程序上是满足原子性的,执行过程中不会被打断。

  2. Pipeline 不会阻塞服务端,但 Transaction 会阻塞服务端

    实际应用中使用 LUA 脚本来满足原子性要比 Transaction 高效得多。考虑网络吞吐问题,需要高频执行的脚本甚至可以预加载到服务端,需要调用时直接传脚本 SHA1(在预加载时会由服务端发回) + 参数 来进行调用。

LUA

值得注意的一点是启动程序时可以将 LUA 脚本预加载到 Redis 服务器

@PostConstruct
public void loadScript() {
    String execute = strRedisTemplate.execute((RedisCallback<String>) connection ->
            connection.scriptLoad(flashSaleIfExistScript.getScriptAsString().getBytes()));
}

scriptLoad 的返回结果是脚本对应的 SHA1,但是我们其实并不需要保存,因为RedisScript 本身在加载脚本时会计算一次 SHA1 ,可以直接通过 getSha1() 获得。

需要调用时直接传脚本 SHA1(在预加载时会由服务端发回) + 参数 来进行调用。Redis 支持的脚本执行指令包括 evalevalsha 两个,Redis Template 将这两条指令都封装在了 org.springframework.data.redis.connection 包中 RedisScriptingCommands 接口下。

ScriptExecutor 在执行时会首先通过 evalsha 来执行脚本,如果 Redis 脚本缓存中没有对应脚本再退回到 eval 方法。

根据以上官方文档的描述,我们不需要主动调用 evalshaScriptExecutor 会自动替我们完成。那我们来看看 Spring Data Redis 源码怎么写的,先找到 DefaultScriptExecutor

public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
    return this.execute(script, this.template.getValueSerializer(), this.template.getValueSerializer(), keys, args);
}
public <T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args) {
    return this.template.execute((connection) -> {
        ReturnType returnType = ReturnType.fromJavaType(script.getResultType());
        byte[][] keysAndArgs = this.keysAndArgs(argsSerializer, keys, args);
        int keySize = keys != null ? keys.size() : 0;
        if (!connection.isPipelined() && !connection.isQueueing()) {
            return this.eval(connection, script, returnType, keySize, keysAndArgs, resultSerializer);
        } else {
            connection.eval(this.scriptBytes(script), returnType, keySize, keysAndArgs);
            return null;
        }
    });
}

可以看到,除非在管道或队列操作中,否则所有的 execute 最终都会落到 eval 这个方法上。

protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys, byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {
    Object result;
    try {
        result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
    } catch (Exception var9) {
        if (!ScriptUtils.exceptionContainsNoScriptError(var9)) {
            throw var9 instanceof RuntimeException ? (RuntimeException)var9 : new RedisSystemException(var9.getMessage(), var9);
        }
        result = connection.eval(this.scriptBytes(script), returnType, numKeys, keysAndArgs);
    }
    return script.getResultType() == null ? null : this.deserializeResult(resultSerializer, result);
}

确实,evalSha 如果不能得到正确结果就会抛异常,转而用 eval 方法了。这对于不预载脚本的场景是不是低效了点?

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus