参考:
小林:Redis面试篇
shuxiaohua:Jedis分析一-Pipeline is currently not supported for JedisClusterConnection.
java guide: Redis常见面试题总结(下)
javap: Redis客户端使用技巧,Redis连接池原理
一 Pipeline
管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端(服务端是多次响应),这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。
但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。
要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。
pipeline并不是redis的设计,只要是基于TCP的长连接,而且访问协议为简单的 CS 请求/应答,都能去实现pipeline。
普通命令模式,如下图所示:
管道模式,如下图所示:
Pipline 是原子的吗?
管道不是原子的。想象一下,有 2 个客户端连接与同一个 Redis 服务器通信,并且两者同时发送一个由 5 个命令组成的管道。虽然可以保证来自客户端 1 管道的所有命令都将按顺序执行,但服务端并不会保证它们不会与来自客户端 2 管道的命令交错。所以 pipeline 不具备原子性。而接下来的 redis 事务 multi 是在服务端去保证命令不可分割,其它命令并不会插入其中(也只有弱原子性)。
二 Redis 事务
Redis 可以通过 MULTI
,EXEC
,DISCARD
和 WATCH
等命令,以及客户端 pipeline 来实现事务(Transaction)功能。
- multi:用于标记事务块的开始,Redis会将同一个客户端连接后续的命令逐个放入队列中,然后使用exec原子化地执行这个命令队列
- exec:执行命令队列
- discard:清除命令队列
- watch:监视key。开启MULTI事务之前输入wacth命令,当调用
EXEC
命令执行事务时,如果一个被WATCH
命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。 - unwatch:清除监视key
multi 用法
muti 开始后的命令是一个个发给服务端的,服务端这时会给每个命令返回一个 "QUEUED",等待 exec 执行完命令队列之后,再把这些命令的真正执行结果一次返回来。
> MULTI OK > SET PROJECT "JavaGuide" QUEUED > GET PROJECT QUEUED > EXEC 1) OK 2) "JavaGuide"
discard 用法
#读取 count 的值4 127.0.0.1:6379> GET count "1" #开启事务 127.0.0.1:6379> MULTI OK #发送事务的第一个操作,对count减1 127.0.0.1:6379> DECR count QUEUED #执行DISCARD命令,主动放弃事务 127.0.0.1:6379> DISCARD OK #再次读取a:stock的值,值没有被修改 127.0.0.1:6379> GET count "1"
watch 用法
# 客户端 1 > SET PROJECT "RustGuide" OK # 在 MULTI 前 WATCH > WATCH PROJECT OK > MULTI OK > SET PROJECT "JavaGuide" QUEUED # 客户端 2 # 在客户端 1 WATCH 后,执行 EXEC 命令提交事务之前,修改 PROJECT 的值 > SET PROJECT "GoGuide" # 客户端 1 # 修改失败,因为 PROJECT 的值被客户端2修改了 > EXEC (nil) > GET PROJECT "GoGuide"
Redis 事务是原子性的吗?
- muti 用于标记事务块的开始,Redis会将同一个客户端连接后续的命令逐个放入队列中,然后使用exec原子化地执行这个命令队列。
- muti 开始后的命令是一个个发给服务端的,服务端这时会给每个命令返回一个 "QUEUED",等待 exec 执行完命令队列之后,再把这些命令的真正执行结果一次返回来。
刚开始我想到的一个问题是,同一个 redis 客户端本身是多线程的,在一个线程中中开启 multi,其他线程不会插入命令到其中吗?就不是原子性的了。
后面搞清楚了,这里 “客户端” 的概念是对 redis 来说的,每一个 tcp 连接都是一个客户端,你的一个 java 应用进程一般会使用连接池与 redis 建立多个连接,那么对于 redis 来说,你就是多个客户端,而不是一个客户端。
类似地,当 Spring 遇到 @Transactional 注解时,会自动从数据库连接池中获取 connection,并开启事务然后绑定到 ThreadLocal 上,对于@Transactional 注解包裹的整个方法都是使用同一个connection连接。题外话:如果我们出现了耗时的操作,比如第三方接口调用,业务逻辑复杂,大批量数据处理等就会导致占用这个 connection 的时间会很长,数据库连接一直被占用不释放。一旦类似操作过多,就会导致数据库连接池耗尽。
所以 redis 客户端(这里指你的应用进程)实现事务,也一定是像数据库那样,一个事务用一个 redis connection(tcp),这样就不会有其它事务的命令插入其中,而对于 redis 服务端来说,这一个连接就是一个 redis 客户端。如下面这段代码:
public List<Object> mutilIncr(String key1, Long value1, String key2, Long value2) { RedisConnection redisConnection = null; List<Object> result = null; try { redisConnection = redisConnectionFactory.getConnection(); ProxyJedis jedis = (ProxyJedis) redisConnection.getNativeConnection(); Transaction tx = jedis.multi(); tx.incrBy(key1, value1); tx.incrBy(key2, value2); result = tx.exec(); } finally { // 执行完之后就释放连接 if (redisConnection != null) { redisConnection.close(); } } return result; }
这样Redis服务端将【同一个客户端连接】一个事务 multi 后的命令逐个放入队列中,然后使用exec原子化地执行这个命令队列。就保证了这些命令是不可分割的,中间不会插入别的事务的命令。
原子性(Atomicity):一个事务中的所有操作,不可分割,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
redis 事务可以保证一个事务中的所有操作不可分割,不被别的命令打断。那么 redis 可以保证事务在执行过程中发生错误而回滚吗?
- 答案是否,Redis 事务不可以实现命令回滚。所以说 Redis 事务是弱原子性
Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃出错后的命令执行,把暂存的命令队列清空,而之前已经执行成功的命令不能回滚。如下面这个例子:
#获取name原本的值 127.0.0.1:6379> GET name "xiaolin" #开启事务 127.0.0.1:6379> MULTI OK #设置新值 127.0.0.1:6379(TX)> SET name xialincoding QUEUED #注意,这条命令是错误的 # expire 过期时间正确来说是数字,并不是‘10s’字符串,但是还是入队成功了 127.0.0.1:6379(TX)> EXPIRE name 10s QUEUED #提交事务,执行报错 #可以看到 set 执行成功,而 expire 执行错误。 127.0.0.1:6379(TX)> EXEC 1) OK 2) (error) ERR value is not an integer or out of range #可以看到,name 还是被设置为新值了 127.0.0.1:6379> GET name "xialincoding"
为什么Redis 不支持事务回滚?
Redis 官方文档 (opens new window)的解释如下:
大概的意思是,作者不支持事务回滚的原因有以下两个:
- 他认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能;
- 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。
这里不支持事务回滚,指的是不支持事务运行时错误的事务回滚。
三 Redis Lua 脚本
redis执行lua脚本时可以简单的认为仅仅只是把命令打包执行了,命令还是依次执行的,只不过在lua脚本执行时是阻塞的,避免了其他指令的干扰。redis 只有一个 Lua 脚本解释器。
Redis 的事务模式,以及 Lua 脚本,都具备如下特点:
- 保证隔离性;
- 无法保证持久性;
- 具备了一定的原子性,但不支持回滚;
- 一致性的概念有分歧,假设在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。
lua 脚本也不能实现出错后回滚之前已经执行成功的命令,也是弱原子性的。在以下这些场景不能保证原子性(和 multi 的场景一样)
- 指令语法错误,如上述执行redis.call(‘het’,‘k1’,‘1’),正确的应该是redis.call(‘het’,‘k1’,‘1’,‘2’)
- 语法是正确的,但是类型不对,比如对已经存在的string类型的key,执行hset等
- 服务器挂掉了,比如lua脚本执行了一半,但是服务器挂掉了
可以看到,Lua 脚本的上述特性与 Multi 基本机制,但其实 Lua 脚本优于 Multi,关键在下面的第一点:
- Redis 事务 Multi 在一次事务中要等 exec 执行完全部命令后一次性返回之前的结果;而 Lua 脚本可以支持一个事务中后面的步骤依赖前面步骤的结果。
- 减少网络开销。将多个请求通过脚本的形式一次发送,减少网络时延。
redis Lua 脚本示例:
当一个业务应用的访问用户增加时,我们有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。
那该怎么限制呢?我们可以把客户端 IP 作为 key,把客户端的访问次数作为 value,保存到 Redis 中。客户端每访问一次后,我们就用 INCR 增加访问次数。不过,在这种场景下,客户端限流其实同时包含了对访问次数和时间范围的限制,例如每分钟的访问次数不能超过 20。所以,我们可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为 60s 后过期。同时,在客户端每次访问时,我们读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。
这个例子中的操作 无法用 Redis 单个命令(由于 redis 执行命令是串行操作,所以单个命令具有天然的原子性)来实现,此时,我们就可以使用 Lua 脚本来保证原子性。我们可以把访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作写入一个 Lua 脚本,如下所示:
local current current = redis.call("incr",KEYS[1]) if tonumber(current) == 1 then redis.call("expire",KEYS[1],60) end
试想一下,上面这个 Lua 脚本完成的任务,用 Multi 是无法实现的,因为要判断 current 来设置过期时间,后面的操作依赖于前面的结果。
假设我们编写的脚本名称为 lua.script,我们接着就可以使用 Redis 客户端,带上 eval 选项,来执行该脚本。脚本所需的参数将通过以下命令中的 keys 和 args 进行传递。
redis-cli --eval lua.script keys , args
这样一来,访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis 也会依次串行执行脚本代码,避免了并发操作带来的数据错误。
四 Redis 集群支持事务或 Lua 脚本吗?
集群模式下,每个节点分配了不同的槽,不同的 redis key 经过 hash 计算后存在于不同的节点上。
集群模式下,当操作的key不属于该节点,服务会发送重定向的响应,让客户端去找正确的节点重新发送请求。
从上图可以看到“客户端一”向两个服务端发送EXEC提交事务时,存在一个时间差,原子性无法得到保证,因此无法做到事务里面的语句在所有节点上一起同时执行。服务端节点之间也并不会为事务或Lua脚本做出协同。
所以,集群模式下。只有操作的所有 key 都是在同一个节点上的(一般只有是同一个 key 能保证这一点),multi 事务 和 Lua 脚本才有意义