前言
Redis 单机是支持事务的,Redis 的事务是下面 4 个命令来实现:
1.multi 开启 Redis 的事务,置客户端为事务态。
2.exec 提交事务,执行从 multi 到此命令前的命令队列,置客户端为非事务态。
3.discard 取消事务,置客户端为非事务态。
4.watch 监视键值对,作用时如果事务提交 exec 时发现监视的键值对发生变化,事务将被取消。
将是否有 watch 命令分为普通类型事务和 CAS(Check And Set)类型事务,无 watch 命令的为普通类型事务,有 watch 命令的为 CAS 类型事务。
但是对于 Redis 集群来说,以上这些命令都不支持集群模式,当使用 spring-data-redis 的 RedisTemplate 在集群中设置了 setEnableTransactionSupport (true) 时,执行命令就会报 MUTLI is currently not supported in cluster mode. 如果是使用 Jedis 的 JedisCluster,可以看到 JedisCluster 里没有 multi 命令。
如何实现 Redis 的事务,保证业务的原子性?
Redis 在 2.6 以后的版本中增加了 Lua 脚本的功能,可以通过 eval 命令,直接在 RedisServer 环境中执行 Lua 脚本,并且可以在 Lua 脚本中调用 Redis 命令。
使用脚本的好处:
1. 减少网络开销:可以把一些要批量处理的功能,发在一个脚本里面执行,减少客户端和 redis 的交互次数。
2. 原子操作:这主要就是我们在这边主要利用的功能,在分布式环境下保证数据的原子性。
3. 复用:客户端发送的脚本会永久的存储在 redis 中,这就意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。
于是使用以下 Java 代码,执行一串 lua 脚本:
String script = "redis.call('LPUSH', KEYS[1], ARGV[1]);"
+ "local is_exists = redis.call('EXISTS', KEYS[1]);"
+ "if is_exists == 1 then\n"
+ "redis.call('HINCRBY', KEYS[2], ARGV[2], 1);\n"
+ "else\n"
+ "redis.call('HSET', KEYS[2], ARGV[2], 1);\n"
+ "end";
List<String> keys = Arrays.asList("key1", "key2");
List<String> args = Arrays.asList("a1", "a2");
jedisCluster.eval(script, keys, args);
但是程序仍旧报错:No way to dispatch this command to Redis Cluster because keys have different slot。
查看 eval 源码,发现在 run 方法中是这样实现的:
public T run(int keyCount, String... keys) {
if (keys == null || keys.length == 0) {
throw new JedisClusterException("No way to dispatch this command to Redis Cluster.");
}
// For multiple keys, only execute if they all share the
// same connection slot.
if (keys.length > 1) {
int slot = JedisClusterCRC16.getSlot(keys[0]);
for (int i = 1; i < keyCount; i++) {
int nextSlot = JedisClusterCRC16.getSlot(keys[i]);//这里查找对应的slot
if (slot != nextSlot) {
throw new JedisClusterException("No way to dispatch this command to Redis Cluster "
+ "because keys have different slots.");
}
}
}
}
实际上,假设在 6 节点集群中,有 3 个 master,3 个 slave,每个 slave 都有对应的 masterid,每个 master 都有对应的 slot 范围。在 ClusterNodeInformationParser 中,去解析每一行并将对应的 slot 填充进去,因为只有 master 上有 slot,因此不会填充 slave 的 slot。因此,当我们正常地通过访问 JedisCluster 的 get/set 时,通过计算 key 的 slot 来获取对应的 Jedis Connection,根本不会使用到 slave,只会访问 master 节点。只有一种情况,在 tryRandomMode 开启时(此时,正常通过 slot 无法获取有效连接时,可能考虑重新排序)。可以看出 redis cluster 的 slot 范围:0-16383,可以采用二分查找的方式,以上面为例,可以分成 3 个部分的范围 slot,以其开头为标识,通过 Collections.binarySearch 来进行二分查找搜索。当拿到各个 redis key 后,通过 getSlotByKey 方法,获得对应的 node 编号,最后,当批量查询的 keys 数组 > 2 时,再进行批量出,否则,只进行单独查询。
尽管两个 slot 在同一个连接上能够 get 到值,但是在 cluster 模式下,是通过 slot 判断而非节点 node 判断是否可以进行 mget 操作,不能靠跳过 jedis 客户端的方案来完成类似分组操作。
因此,只能通过 HASH_TAG 来实现 cluster 模式下的 mget/mset 批量操作,我们可以在命令行中通过 cluster keyslot ${key} 来查看某个 key 对应的 slot,可以从 Jedis 客户端的源码查看对应的 keyslot 算法。keyslot 算法中,如果 key 包含 {},就会使用第一个 {} 内部的字符串作为 hash key,这样就可以保证拥有同样 {} 内部字符串的 key 就会拥有相同 slot。但是这样的话,本来可以 hash 到不同的 slot 中的数据都放到了同一个 slot 中,所以使用的时候要注意数据不要太多导致一个 slot 数据量过大,数据分布不均匀!
因此,对于多个 key 的操作,在 redis 集群中,仍然无法使用事务,保证其原子性。