抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >
  • 虽然Redis是单线程的工作模式,但是你是否考虑过是否还会出现并发问题吗?

  • Redis是基于C/S架构,支持多个客户端请求,多个客户端的访问顺序能保证吗?

  • 本篇主要从多客户端执行多个命令入手,介绍事务、Lua脚本和管道的应用场景

Github issues:https://github.com/littlejoyo/Blog/issues/

1.Redis什么场景下会出现并发问题?

  • Redis是基于C/S架构,支持多个客户端请求,然后Redis服务并没有对每个请求进行同步处理,也就是不保证命令执行的有序性

  • 当一个客户端存在先读后写的多个执行指令时,不能保证其顺序就可能出现并发问题

举个例子:

1.有一个共同访问的key="joyo",当前value=1,然后客户端有下面操作,如果value>0,就执行递减操作decr joyo(减1操作),否则直接返回

2.现有A和B两个客户端发送请求到服务端,每个客户端都有12两个指令,分别对应读和写

3.理想情况的顺序是A1->A2->B1->B2或者B1->B2->A1->A2,这样就能保证key的最终value是0,但是多客户端下,各个执行的顺序是不能保证的

4.但是实际上,很可能会出现A1->B1->A2->B2或者B1->A1->B2->B1,这样A和B都能执行减1的操作,因为都会读取到value=1,最终的结果就会是-1,这个时候就是因为并发问题导致业务bug的发生了

2.如何解决并发问题?

2.1 redis事务+watch

  • 使用redis事务可以保证当前客户端的指令执行不会被其他客户端打断的,也就是满足事务的隔离性

  • 但是,在一个事务中,只有当所有命令都依次执行完后才能得到每个结果的返回值,可是有些情况下需要先获得一条命令的返回值,然后再根据这个返回值进行业务判断后,才去执行下一条命令,比如我们这里就需要先去读取到key="joyo"的值,如果value>0,再去执行下一条命令

  • 此时,对于get命令,这个时候我们是放在事务之外的,也就是当前事务体中只有一条递减的指令decr,是否能执行取决于读指令get取到的value

  • 最终还是可能出现客户端A和B读取到value都是1,如,A1->B1->A2->B2或者B1->A1->B2->B1,此时watch命令就派上用场了

  • watch命令能够监控key是否被修改过,如果发现被修改了就不执行事务的操作,直接返回

  • 如果此时的命令执行顺序为A1->B1->A2->B2,表示此刻客户端A先执行了事务,读取到value=1,对key进行了减1的操作,value变为0,然后轮到客户端B执行的时候就会发现key已经被修改了,所以不执行事务然后直接返回,所以最终结果会是value=0

1
2
3
4
5
6
7
8
9
10
11
// 读取
redis> get joyo // A1=1 B1=1

// 监控key (如果发现已经被修改就不执行下面的事务)
redis> watch joyo
// 事务操作
redis>MULTI
OK
redis>DECR joyo
QUEUED
redis>EXEC

redis事务参考另一篇文章:【Redis】:正确认识Redis的事务机制

2.2 使用lua脚本

  • Redis 从 2.6 版本开始在服务器内部嵌入了一个 Lua 解释器,使得用户可以在服务器端执行 Lua 脚本

  • Redis提供了eval指令,只需要传入lua脚本对应参数就能执行具备原子性操作的指令集合

  • 还有一点就是,所有脚本都是以事务的形式来执行的,脚本在执行过程中不会被其他工作打断,也不会引起任何竞争条件,完全可以使用 Lua 脚本来代替事务和乐观锁

  • 如果当前Redis服务端正在执行lua执行脚本,不会再接受其他指令,知道lua脚本执行完成后再去执行别的指令,因此,写lua脚本的时候切记不要写耗时过长的操作,避免出现死循环语句

如何解决上面的问题呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 写一段lua脚本
"if redis.call('get', KEYS[1]) > 0 then return redis.call('decr', KEYS[1]) else return 0 end"
// 假设当前key=joyo的值为1
redis> get joyo
redis> 1

// redis客户端执行
// 第一次执行
redis> eval "if redis.call('get', KEYS[1]) > 0 then return redis.call('decr', KEYS[1]) else return 0 end" 1 joyo
redis> 1
// 再次执行
redis> eval "if redis.call('get', KEYS[1]) > 0 then return redis.call('decr', KEYS[1]) else return 0 end" 1 joyo
redis> 0
  • 第一次执行的时候,读取到value=1,所以执行了递减操作

  • 第二次以及后面都读取到的value=0,直接返回0

  • 通过lua语句保证了指令执行的原子性还有有序性,因为是一次性执行的

3.Lua脚本的好处

  1. 使用脚本可以直接在服务器端执行 Redis 命令,一般的数据处理操作可以直接使用 Lua 语言或者Lua 解释器提供的函数库来完成,不必再返回给客户端进行处理。

  2. 所有脚本都是以事务的形式来执行的,脚本在执行过程中不会被其他工作打断,也不会引起任何竞争条件,完全可以使用 Lua 脚本来代替事务和乐观锁。

  3. 所有脚本都是可重用的,重复执行相同的操作时,只要调用储存在服务器内部的脚本缓存就可以了,不用重新发送整个脚本,从而尽可能地节约网络资源,(redis提供了evalsha的指令)

4.如何在Redis中使用Lua?

4.1 基本语法

  • 基本语法如下:
1
redis 127.0.0.1:6379> EVAL script numkeys key [key ...] arg [arg ...]
  • EVAL: Redis执行Lua脚本的指令

  • script: Lua脚本(字符串格式)

  • numkeys: 脚本要处理的数据库键的数量,指明后面key数组的长度

  • key [key …]: 指定了脚本要处理的数据库键,被传入的键可以在脚本里面通过访问 KEYS 数组来取得,比如 KEYS[1] 就取出第一个输入的键,KEYS[2] 取出第二个输入的键,诸如此类。

  • arg [arg …]: 指定了脚本要用到的参数,在脚本里面可以通过访问 ARGV 数组来获取这些参数。显式地指定脚本里面用到的键是为了配合 Redis 集群对键的检查,如果不这样做的话,在集群里面使用脚本可能会出错。

1
2
3
4
5
6
7
8
9
redis> EVAL "return 'hello joyo'" 0
"hello joyo"
redis> EVAL "return 1+2" 0
(integer) 3
redis> EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 "name" "age" "joyo" 18
1) "name" # KEYS[1]
2) "age" # KEYS[2]
3) "joyo" # ARGV[1]
4) 18 # ARGV[2]

4.2 如果调用Redis命令?

  • 通过调用 redis.call() 函数或者 redis.pcall() 函数,就可以直接在 Lua 脚本里面执行 Redis 命令。
1
2
3
4
5
6
7
8
9
10
11
// 在 Lua 脚本里面执行 PING 命令
redis> EVAL "return redis.call('PING')" 0
PONG
// 在 Lua 脚本里面执行 DBSIZE 命令
redis> EVAL "return redis.call('DBSIZE')" 0
(integer) 16
// 在 Lua 脚本里面执行 GET 命令,取出键 msg 的值,并对值进行字符串拼接操作
redis> SET msg "hello joyo"
OK
redis> EVAL "return 'The message is: ' .. redis.call('GET', KEYS[1]) '" 1 msg
"The message is: hello joyo"

4.3 redis.call和redis.pcall的区别

  • redis.call()redis.pcall() 都可以用来执行 Redis 命令

  • 不同的地方表现为,当被执行的脚本出错时,redis.call() 会返回出错脚本的名字以及 EVAL 命令的错误信息,而 redis.pcall() 只返回 EVAL 命令的错误信息。

  • 在被执行的脚本出错时, redis.call() 可以提供更详细的错误信息,方便进行查错

  • 使用时一定要注意 call 函数出错时会中断脚本的执行,而 pcall 会继续执行后面的内容

1
2
3
4
5
6
7
8
## 执行call
redis> EVAL "return redis.call('NotExistsCommand')" 0
(error) ERR Error running script (call to f_ddabd662fa0a8e105765181ee7606562c1e6f1ce):
@user_script:1: @user_script: 1: Unknown Redis command called from Lua script

## 执行pcall
redis> EVAL "return redis.pcall('NotExistsCommand')" 0
(error) @user_script: 1: Unknown Redis command called from Lua script

4.4 EVALSHA命令减少网络资源损耗

  • 任何 Lua 脚本,只要被 EVAL 命令执行过一次,就会被储存到服务器的脚本缓存里面

  • 然后其实用户只要通过EVALSHA 命令,指定被缓存脚本的 SHA1 值,就可以在不发送脚本的情况下,再次执行脚本:
    EVALSHA sha1 numkeys key [key ...] arg [arg ...]

  • 如若每次执行EVAL都需要读取一大段Lua脚本会造成不必要的网络加载开销,通过SHA1值可以进行脚本的重用,那么如何生成这个SHA1值呢?

  • Redis提供了SCRIPTLOAD命令可以生成Lua脚本的SHA1值

1
2
3
4
5
6
7
redis> EVAL "return 'hello world'" 0
"hello world"
## 生成SHA1值
redis> SCRIPTLOAD "return 'hello world'" 0
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
"hello world"

4.5 Lua脚本管理命令

命令 描述
SCRIPT EXISTS 检查 sha1 值所代表的脚本是否已经被加入到脚本缓存里面,是的话返回 1 ,不是的话返回 0
SCRIPT LOAD script 将脚本储存到脚本缓存里面,等待将来 EVALSHA 使用
SCRIPT FLUSH 清除脚本缓存储存的所有脚本
SCRIPT KILL 杀死运行超时的脚本。如果脚本已经执行过写入操作,那么还需要使用 SHUTDOWN NOSAVE 命令来强制服务器不保存数据,以免错误的数据被保存到数据库里面。

4.6 Lua函数库

  • Lua语言提供了各种函数库可以供程序员使用,直接写在脚本中即可有调用

  • 具体的使用参考Lua的语法:Lua语法入门

函数库 描述
base 库 包含 Lua 的核心(core)函数,比如 assert、tostring、error、type 等
string 库 包含用于处理字符串的函数,比如 find、format、len、reverse 等
table 库 包含用于处理表格的函数,比如 concat、insert、remove、sort 等
math 库 包含常用的数学计算函数,比如 abs、sqrt、log 等
debug 库 包含调试程序所需的函数,比如 sethook、gethook 等,以及外部库
struct 库 在 C 语言的结构和 Lua 语言的值之间进行转换
cjson 库 将 Lua 值转换为 JSON 对象,或者将 JSON 对象转换为 Lua 值
cmsgpack 库 将 Lua 值编码为 MessagePack 格式,或者从 MessagePack 格式里面解码出 Lua值

另外还有一个用于计算 sha1 值的外部函数 redis.sha1hex。

5.Lua脚本出现死循环怎办?

  • 此时有一个问题要思考,Redis的指令执行是个单线程,如果当前执行的的lua 脚本中出现了死循环,是不是 Redis 服务也就完全卡死没用了?

  • 此时的确会导致Redis无法提供服务,但是Redis提供了终止脚本死循环的命令:SCRIPT KILL

  • 如果当前出现了死循环的话,解决方案就是另起一个Redis客户端执行:SCRIPT KILL命令来终止脚本的执行

不过 SCRIPT KILL 的执行有一个重要的前提,那就是当前正在执行的脚本没有对 Redis 的内部数据状态进行修改,因为 Redis 不允许 SCRIPT KILL 破坏脚本执行的原子性。

思考:此时Redis都卡死了,为什么还可以执行SCRIPT KILL指令?

  • Lua脚本引擎功能提供了各式各样的钩子函数,它允许在内部虚拟机执行指令时运行钩子代码。

  • 比如每执行 N 条指令执行一次某个钩子函数,Redis 正是使用了这个钩子函数。

  • Redis 在钩子函数里会忙里偷闲去处理客户端的请求,并且只有在发现 Lua 脚本执行超时之后才会去处理请求,这个超时时间默认是 5 秒,因此执行SCRIPT KILL指令可能需要等待几秒后才返回执行成功

6.Redis事务和Lua脚本的对比

  • 虽然使用事务可以一次执行多个命令,并且通过乐观锁可以防止事务产生竞争条件,但是在实际中,要正确地使用事务和乐观锁并不是一件容易的事情。

  • 对于一个业务场景需要考虑需要对哪些键加锁,给不相关的key加锁或者相关的key却不加锁,都会出现意外的错误,因此需要仔细结合业务场景进行全面的综合考虑,需要有一个思考的过程

  • 另外一个就是引入事务和乐观锁会让代码显得更加复杂,还有带来额外的损耗

  • 相比较之下,Lua脚本可能更加容易接受,上面已经总结了使用Lua脚本的有点,缺点就是需要保证好Lua脚本的准确性,相比较增加了新一门语言语法的掌握,值得庆幸的是Lua基本语法还算简单易懂。

  • Lua保证了脚本执行的原子性,在当前脚本没执行完之前,别的命令和脚本都是等待状态,所以一定要控制好脚本中的内容,防止出现需要消耗大量时间的内容(逻辑相对简单)

7.管道

最后简单讲一下关于管道和事务、Lua脚本的区别

  • Redis是基于TCP连接进行通信的,每一个请求/响应过程都需要经历一个RTT往返时间,如果需要执行很多短小的命令,这些往返时间的开销是很大的,在此情形下,redis提出了管道来提高执行效率。

  • 管道的思想是:如果client执行一些相互之间无关的命令或者不需要获取命令的返回值,那么redis允许你连续发送多条命令,而不需要等待前面命令执行完毕。

  • 比如我们执行3条INCR命令,如果使用管道,理论上只需要一个RTT+3条命令的执行时间即可,如果不适用管道,那么可能需要额外的两个RTT时间。因此,管道相当于批处理脚本,相当于是命令集,可以理解为复用了当前的TCP连接完成所有命令的执行。

注意:管道中的多个命令,如果其中一个出现执行错误,仍然会去执行下一个命令,不会停止。

  • 使用管道可能在效率上比使用Lua脚本要好,但是有的情况下只能使用script。因为管道在执行后面的命令时,无法得到前面命令的结果,就像事务一样,所以如果需要在后面命令中使用前面命令的value等结果,则只能使用script或者事务+watch。

微信公众号

扫一扫关注Joyo说公众号,共同学习和研究开发技术。

weixin-a

评论