Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【Redis】:Redis事务、Lua事务和管道的实践探究 #39

Open
littlejoyo opened this issue Jul 27, 2020 · 0 comments
Open

【Redis】:Redis事务、Lua事务和管道的实践探究 #39

littlejoyo opened this issue Jul 27, 2020 · 0 comments
Assignees
Labels
Redis Redis中间件知识点

Comments

@littlejoyo
Copy link
Owner

littlejoyo commented Jul 27, 2020

个人博客:https://joyohub.com/

微信公众号:Joyo说

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

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

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

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

// 读取
redisget joyo // A1=1 B1=1

// 监控key (如果发现已经被修改就不执行下面的事务)
rediswatch joyo
// 事务操作
redisMULTI
OK
redisDECR joyo 
QUEUED
redisEXEC

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

2.2 使用lua脚本

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

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

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

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

如何解决上面的问题呢?

// 写一段lua脚本
"if redis.call('get', KEYS[1]) > 0 then return redis.call('decr', KEYS[1]) else return 0 end"
// 假设当前key=joyo的值为1
redisget joyo
redis1

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

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

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

3.Lua脚本的好处

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

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

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

4.如何在Redis中使用Lua?

4.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 集群对键的检查,如果不这样做的话,在集群里面使用脚本可能会出错。

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 命令。
// 在 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 会继续执行后面的内容

## 执行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值

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

@littlejoyo littlejoyo added the Redis Redis中间件知识点 label Jul 27, 2020
@littlejoyo littlejoyo self-assigned this Jul 27, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Redis Redis中间件知识点
Projects
None yet
Development

No branches or pull requests

1 participant