龙空技术网

Redis弱事务性与Lua脚本原子性分析

IT动力 3143

前言:

如今我们对“nginxlua路由”大致比较着重,我们都需要剖析一些“nginxlua路由”的相关资讯。那么小编在网上收集了一些有关“nginxlua路由””的相关文章,希望我们能喜欢,我们快快来学习一下吧!

1、什么是事务?

简单来说,事务(transaction)是指单个逻辑单元执行的一系列操作。

1.1、事务的四大特性ACID

事务有如下四大特性:

1、原子性(Atomicity): 构成事务的所有操作都必须是一个逻辑单元,要么全部执行,要么全不执行2、一致性(Consistency): 数据库在事务执行前后状态都必须是稳定的或者一致的。A(1000)给B(200)转账100后A(900),B(300)总和保持一致。3、隔离性(Isolation): 事务之间相互隔离,互不影响。4、持久性(Durability): 事务执行成功后数据必须写入磁盘,宕机重启后数据不会丢失。2、Redis中的事务

Redis中的事务通过multi,exec,discard,watch这四个命令来完成。

Redis的单个命令都是原子性的,所以确保事务的就是多个命令集合一起执行。

Redis命令集合打包在一起,使用同一个任务确保命令被依次有序且不被打断的执行,从而保证事务性。

Redis是弱事务,不支持事务的回滚。

2.1、事务命令

事务命令简介

1、multi(开启事务)用于表示事务块的开始,Redis会将后续的命令逐个放入队列,然后使用exec后,原子化的执行这个队列命令。类似于mysql事务的begin2、exec(提交事务)执行命令队列类似于mysql事务的commit3、discard(清空执行命令)清除命令队列中的数据类似于mysql事务的rollback,但与rollback不一样 ,这里是直接清空队列所有命令,从而不执行。所以不是的回滚。就是个清除。4、watch监听一个redis的key 如果key发生变化,watch就能后监控到。如果一个事务中,一个已经被监听的key被修改了,那么此时会清空队列。5、unwatch取消监听一个redis的key

事务操作

# 普通的执行多个命令127.0.0.1:6379> multiOK127.0.0.1:6379> set m_name zhangsanQUEUED127.0.0.1:6379> hmset m_set name zhangsan age 20 QUEUED127.0.0.1:6379> exec1) OK2) OK# 执行命令前清空队列 将会导致事务执行不成功127.0.0.1:6379> multiOK127.0.0.1:6379> set m_name_1 lisiQUEUED127.0.0.1:6379> hmset m_set_1 name lisi age 21QUEUED# 提交事务前执行了清空队列命令127.0.0.1:6379> discardOK127.0.0.1:6379> exec(error) ERR EXEC without MULTI# 监听一个key,并且在事务提交之前改变在另一个客户端改变它的值,也会导致事务失败127.0.0.1:6379> set m_name_2 wangwu01OK127.0.0.1:6379> watch m_name_2OK127.0.0.1:6379> multiOK127.0.0.1:6379> set m_name_2 wangwu02QUEUED# 另外一个客户端在exec之前执行之后,这里会返回nil,也就是清空了队列,而不是执行成功127.0.0.1:6379> exec(nil)# 另外一个客户端在exec之前执行127.0.0.1:6379> set m_name_2 niuqiOK
2.2、事务机制分析

我们前面总是在说,Redis的事务命令是打包放在一个队列里的。那么来看一下Redis客户端的数据结构吧。

client数据结构

typedef struct client {    // 客户端唯一的ID    uint64_t id;       // 客户端状态 表示是否在事务中    uint64_t flags;             // 事务状态    multiState mstate;    // ...还有其他的就不一一列举了} client;

multiState事务状态数据结构

typedef struct multiState {    // 事务队列 是一个数组,按照先入先出顺序,先入队的命令在前 后入队的命令在后    multiCmd *commands;     /* Array of MULTI commands */    // 已入队命令数    int count;              /* Total number of MULTI commands */    // ...略} multiState;

multiCmd事务命令数据结构

/* Client MULTI/EXEC state */typedef struct multiCmd {    // 命令的参数    robj **argv;    // 参数长度    int argv_len;    // 参数个数    int argc;    // redis命令的指针    struct redisCommand *cmd;} multiCmd;

Redis的事务执行流程图解

Redis的事务执行流程分析

1、事务开始时,在Client中,有属性flags,用来表示是否在事务中,此时设置flags=REDIS_MULTI2、Client将命令存放在事务队列中,事务本身的一些命令除外(EXEC,DISCARD,WATCH,MULTI)3、客户端将命令放入multiCmd *commands,也就是命令队列4、Redis客户端将向服务端发送exec命令,并将命令队列发送给服务端5、服务端接受到命令队列后,遍历并一次执行,如果全部执行成功,将执行结果打包一次性返回给客户端。6、如果执行失败,设置flags=REDIS_DIRTY_EXEC, 结束循环,并返回失败。2.3、监听机制分析

我们知道,Redis有一个expires的字典用于key的过期事件,同样,监听的key也有一个类似的watched_keys字典,key是要监听的key,值是一个链表,记录了所有监听这个key的客户端。

而监听,就是监听这个key是否被改变,如果被改变了,监听这个key的客户端的flags属性就设置为REDIS_DIRTY_CAS。

Redis客户端向服务器端发送exec命令,服务器判断Redis客户端的flags,如果为REDIS_DIRTY_CAS,则清空事务队列。

redis监听机制图解

redis监听key数据结构

回过头再看一下RedisDb类的watched_keys,确实是一个字典,数据结构如下:

typedef struct redisDb {    dict *dict;                 /* 存储所有的key-value */    dict *expires;              /* 存储key的过期时间 */    dict *blocking_keys;        /* blpop存储阻塞key和客户端对象*/    dict *ready_keys;           /* 阻塞后push,响应阻塞的那些客户端和key */    dict *watched_keys;         /* 存储watch监控的key和客户端对象 WATCHED keys for MULTI/EXEC CAS */    int id;                     /* 数据库的ID为0-15,默认redis有16个数据库 */    long long avg_ttl;          /* 存储对象的额平均ttl(time in live)时间用于统计 */    unsigned long expires_cursor; /* Cursor of the active expire cycle. */    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */    clusterSlotToKeyMapping *slots_to_keys; /* Array of slots to keys. Only used in cluster mode (db 0). */} redisDb;
2.4、Redis的弱事务性

为什么说Redis是弱事务性呢? 因为如果redis事务中出现语法错误,会暴力的直接清除整个队列的所有命令。

# 在事务外设置一个值为test127.0.0.1:6379> set m_err_1 testOK127.0.0.1:6379> get m_err_1 "test"# 开启事务 修改值 但是队列的其他命令出现语法错误  整个事务会被discard127.0.0.1:6379> multi OK127.0.0.1:6379> set m_err_1 test1QUEUED127.0.0.1:6379> sets m_err_1 test2(error) ERR unknown command `sets`, with args beginning with: `m_err_1`, `test2`, 127.0.0.1:6379> set m_err_1 test3QUEUED127.0.0.1:6379> exec(error) EXECABORT Transaction discarded because of previous errors.# 重新获取值127.0.0.1:6379> get m_err_1"test"

我们发现,如果命令队列中存在语法错误,是直接的清除队列的所有命令,并不是进行事务回滚,但是语法错误是能够保证原子性的

再来看一些,如果出现类型错误呢?比如开启事务后设置一个key,先设置为string, 然后再当成列表操作。

# 开启事务127.0.0.1:6379> multi OK# 设置为字符串127.0.0.1:6379> set m_err_1 test_type_1QUEUED# 当初列表插入两个值127.0.0.1:6379> lpush m_err_1 test_type_1 test_type_2QUEUED# 执行127.0.0.1:6379> exec1) OK2) (error) WRONGTYPE Operation against a key holding the wrong kind of valu# 重新获取值,我们发现我们的居然被改变了,明明,事务执行失败了啊127.0.0.1:6379> get m_err_1"test_type_1"

直到现在,我们确定了redis确实不支持事务回滚。因为我们事务失败了,但是命令却是执行成功了。

弱事务总结

1、大多数的事务失败都是因为语法错误(支持回滚)或者类型错误(不支持回滚),而这两种错误,再开发阶段都是可以遇见的2、Redis为了性能,就忽略了事务回滚。

那么,redis就没有办法保证原子性了吗,当然有,Redis的lua脚本就是对弱事务的一个补充。

3、Redis中的lua脚本

lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua应用场景:游戏开发、独立应用脚本、Web应用脚本、扩展和数据库插件。

OpenResty:一个可伸缩的基于Nginx的Web平台,是在nginx之上集成了lua模块的第三方服务器。

OpenResty是一个通过Lua扩展Nginx实现的可伸缩的Web平台,内部集成了大量精良的Lua库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发(日活千万级别)、扩展性极高的动态Web应用、Web服务和动态网 关。 功能和nginx类似,就是由于支持lua动态脚本,所以更加灵活,可以实现鉴权、限流、分流、日志记 录、灰度发布等功能。

OpenResty通过Lua脚本扩展nginx功能,可提供负载均衡、请求路由、安全认证、服务鉴权、流量控 制与日志监控等服务。

类似的还有Kong(Api Gateway)、tengine(阿里)

3.1、Lua安装(Linux)

lua脚本下载和安装

lua脚本参考文档:

# curl直接下载curl -R -O  解压tar zxf lua-5.4.4.tar.gz# 进入,目录cd lua-5.4.4# 编译安装make all test

编写lua脚本

编写一个lua脚本test.lua,就定义一个本地变量,打印出来即可。

local name = "zhangsan"print("name:",name)

执行lua脚本

[root@VM-0-5-centos ~]# lua test.lua name:   zhangsan
3.2、Redis中使用Lua

Redis从2.6开始,就内置了lua编译器,可以使用EVAL命令对lua脚本进行求值。

脚本命令是原子性的,Redis服务器再执行脚本命令时,不允许新的命令执行(会阻塞,不在接受命令)。、

EVAL命令

通过执行redis的eval命令,可以运行一段lua脚本。

EVAL script numkeys key [key ...] arg [arg ...]

EVAL命令说明

1、script:是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。2、numkeys:指定键名参数的个数。3、key [key ...]:从EVAL的第三个参数开始算起,使用了numkeys个键(key),表示在脚本中所用到的哪些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)4、arg [arg ...]:可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似(ARGV[1] 、 ARGV[2] ,诸如此类)

简单来说,就是

eval lua脚本片段  参数个数(假设参数个数=2)  参数1 参数2  参数1值  参数2值

EVAL命令执行

# 执行一段lua脚本 就是把传入的参数和对应的值返回回去127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 name age zhangsan 201) "name"2) "age"3) "zhangsan"4) "20"

lua脚本中调用redis

我们直到了如何接受和返回参数了,那么lua脚本中如何调用redis呢?

1、redis.call返回值就是redis命令执行的返回值如果出错,则返回错误信息,不继续执行2、redis.pcall返回值就是redis命令执行的返回值如果出错,则记录错误信息,继续执行

其实就是redis.call会把异常抛出来,redis.pcall则时捕获了异常,不会抛出去。

lua脚本调用redis设置值

# 使用redis.call设置值127.0.0.1:6379> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 eval_01 001OK127.0.0.1:6379> get eval_01"001"

EVALSHA命令

前面的eval命令每次都要发送一次脚本本身的内容,从而每次都会编译脚本。

Redis提供了一个缓存机制,因此不会每次都重新编译脚本,可能在某些场景,脚本传输消耗的带宽可能是不必要的。

为了减少带宽的西消耗,Redis实现了evaklsha命令,它的作用和eval一样,只是它接受的第一个参数不是脚本,而是脚本的SHA1校验和(sum)。

所以如何获取这个SHA1的值,就需要提到Script命令。

1、SCRIPT FLUSH :清除所有脚本缓存。2、SCRIPT EXISTS :根据给定的脚本校验和,检查指定的脚本是否存在于脚本缓存。3、SCRIPT LOAD :将一个脚本装入脚本缓存,返回SHA1摘要,但并不立即运行它。4、SCRIPT KILL :杀死当前正在运行的脚本

执行evalsha命令

# 使用script load将脚本内容加载到缓存中,返回sha的值127.0.0.1:6379> script load "return redis.call('set',KEYS[1],ARGV[1])""c686f316aaf1eb01d5a4de1b0b63cd233010e63d"# 使用evalsha和返回的sha的值 + 参数个数 参数名称和值执行127.0.0.1:6379> evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 eval_02 002OK# 获取结果127.0.0.1:6379> get eval_02"002"

我们上面都是将脚本写在代码行里面,可以不可以将脚本内容写在xxx.lua中,直接执行呢? 当然是可以的。

使用redis-cli运行外置lua脚本

编写外置脚本test2.lua, 设置值到redis中。

# 脚本内容 也就是设置一个值return redis.call('set',KEYS[1],ARGV[1])# 执行结果,可以使用./redis-cli -h 127.0.0.1 -p 6379 指定redis ip、端口等root@62ddf68b878d:/data# redis-cli --eval /data/test2.lua eval_03 , test03       OK

利用Redis整合lua脚本,主要是为了保证性能是事务的原子性,因为redis的事务功能确实有些差劲!

4、Redis的脚本复制

Redis如果开启了主从复制,脚本是如何从主服务器复制到从服务器的呢?

首先,redis的脚本复制有两种模式,脚本传播模式和命令传播模式。

在开启了主从,并且开启了AOF持久化的情况下。

4.1、脚本传播模式

其实就是主服务器执行什么脚本,从服务器就执行什么样的脚本。但是如果有当前事件,随机函数等会导致差异。

主服务器执行命令

# 执行多个redis命令并返回127.0.0.1:6379> eval "local result1 = redis.call('set',KEYS[1],ARGV[1]); local result2 = redis.call('set',KEYS[2],ARGV[2]); return {result1, result2}" 2 eval_test_01 eval_test_02 0001 00021) OK2) OK127.0.0.1:6379> get eval_test_01"0001"127.0.0.1:6379> get eval_test_02"0002"

那么主服务器将向从服务器发送完全相同的eval命令:

eval "local result1 = redis.call('set',KEYS[1],ARGV[1]); local result2 = redis.call('set',KEYS[2],ARGV[2]); return {result1, result2}" 2 eval_test_01 eval_test_02 0001 0002

注意:在这一模式下执行的脚本不能有时间、内部状态、随机函数等。执行相同的脚本以及参数必须产生相同的效果。在Redis5,也是处于同一个事务中。

4.2、命令传播模式

处于命令传播模式的主服务器会将执行脚本产生的所有写命令用事务包裹起来,然后将事务复制到AOF文件以及从服务器里面.

因为命令传播模式复制的是写命令而不是脚本本身,所以即使脚本本身包含时间、内部状态、随机函数等,主服务器给所有从服务器复制的写命令仍然是相同的。

为了开启命令传播模式,用户在使用脚本执行任何写操作之前,需要先在脚本里面调用以下函数:

redis.replicate_commands()

redis.replicate_commands() 只对调用该函数的脚本有效:在使用命令传播模式执行完当前脚本之后,服务器将自动切换回默认的脚本传播模式。

执行脚本

eval "redis.replicate_commands();local result1 = redis.call('set',KEYS[1],ARGV[1]); local result2 = redis.call('set',KEYS[2],ARGV[2]); return {result1, result2}" 2 eval_test_03 eval_test_04 0003 0004

appendonly.aof文件内容

*1$5MULTI*3$3set$12eval_test_03$40003*3$3set$12eval_test_04$40004*1$4EXEC

可以看到,在一个事务里面执行了我们脚本执行的命令。

同样的道理,主服务器只需要向从服务器发送这些命令就可以实现主从脚本数据同步了。

5、Redis的管道/事务/脚本1、管道其实就是一次性执行一批命令,不保证原子性,命令都是独立的,属于无状态操作(也就是普通的批处理)2、事务和脚本是有原子性的,但是事务是弱原子性,lua脚本是强原子性。3、lua脚本可以使用lua语言编写比较复杂的逻辑。4、lua脚本的原子性强于事务,脚本执行期间,另外的客户端或其他的任何脚本或命令都无法执行。所以lua脚本的执行事件应该尽可能的短,不然会导致redis阻塞不能做其他工作。6、小结

Redis的事务是弱事务,多个命令开启事务一起执行性能比较低,且不能一定保证原子性。所以lua脚本就是对它的补充,它主要就是为了保证redis的原子性。

比如有的业务(接口Api幂等性设计,生成token,(取出toker并判断是否存在,这就不是原子操作))我们需要获取一个key, 并且判断这个key是否存在。就可以使用lua脚本来实现。

还有很多地方,我们都需要redis的多个命令操作需要保证原子性,此时lua脚本可能就是一个不二选择。

7、相关文章

本人还写了Redis的其他相关文章,有兴趣的可以点击查看!

<<Redis持久化机制分析>><<Redis的事件处理机制分析>><<Redis客户端和服务端如何通信?>><<redis的淘汰机制分析>><<Redis的底层数据结构分析>><<Redis的8种数据类型,什么场景使用?>><<缓存是什么?缓存有哪些分类?使用它的代价是什么?>><<缓存的6种常见的使用场景>>

标签: #nginxlua路由 #lua 链表 #lua链表