Redis 事务 vs Lua
前言
Lua 脚本也能保证一定的原子性,这与 Redis 事务机制十分类似,为啥 Redis 会支持两种类似的机制呢?
另外,在 stackoverflow 十几年前的远古帖子上还能看到有人引用官网的话如下:
"we may deprecate and finally remove transactions"
但直至 Redis 7.0 版本,Redis 事务功能依然健在,而新版本的官网也找不到这句话了。
所以,事务和 Lua 到底有何区别?我们应该如何做选择呢?
我们先分别介绍一下事务 与 Lua 机制。
Redis 事务简介
Redis 事务基本使用
Redis 事务基本可以分为如下几个步骤:
1、开启事务:multi
让客户端Client变为事务模式状态
2、命令入列
输入multi后的所有命令会被存储在一个命令队列中,作为事务的一部分
此时,所有命令返回值均为 QUEUED
。
3、执行事务:exec
执行exec,执行事务
4、放弃事务:discard
不会执行事务
Redis 事务有ACID吗
1、原子性
原子性的描述是:一个事务的操作要么全部成功,要么全部失败。Redis 事务是否有原子性这个问题,需要分类讨论,但我们需要先明确一点:Redis 事务不支持回滚。
不支持回滚
Redis 事务并不支持回滚,这与许多关系型数据库截然相反,关于这个问题我找到了一篇官方回答:《为什么 Redis 不支持事务回滚》,非常推荐。简单说就是,Redis 事务的所有命令会被一起执行,并且事务的执行期间,别的客户端发送的命令需要等待事务执行完毕才去执行。加之 Redis 单线程的特性,可以理解为事务获取到了Redis 全局锁。换句话说,只要编码正确,事务总会成功,不会失败。
现在再回到Redis 事务原子性的讨论中:
- 当事务存在「语法错误」,能够被 Redis 在命令入队时检测出,执行EXEC会失败,有原子性
- 当事务存在「运行时错误」,比如命令和操作的数据类型不匹配,此时事务中其它正确的命令仍然会被按序执行,此时无原子性,即上述编码错误的情况
- 当事务正常执行,无任何错误时,有原子性
因此,只要编码正确,Redis 事务总能保证原子性。
2、隔离性
既然 Redis 单线程执行命令,并且「事务的所有命令会被一起执行」,其它客户端的命令此时会被阻塞,因此 Redis 事务执行过程中能保证天然的隔离性。
但是,一个 Redis 事务的生命周期包含两部分:开启到执行前,和执行过程中。
事务开启到正式执行前,可能会存在隔离性问题,Redis 提供了 watch 机制来解决这个问题。
监控 watch:乐观锁
watch使用在 multi 事务开启之前
watch 可以保证 EXEC 前的隔离性,可以理解为一把乐观锁。
使用watch监控若干个key,如果在事务 EXEC 前,某个监控项被修改了,那么在执行 EXEC 命令时,会放弃事务
css
WATCH [key]
使用unwatch可以清除所有监控。
因此,合理地使用 watch 机制,Redis 事务也能保证隔离性。
3、持久性
这取决于 Redis 的持久化配置模式。
但不管 Redis 采用什么持久化模式,都可能出现数据丢失的情况,所以无法保证事务的持久性。
4、一致性
Redis 事务机制对一致性属性是有保证的。
Redis 事务的不足
一旦事务需要依赖于一个 get 操作的结果,就非常不方便。因为事务是一起执行的,我们没办法将一个操作的结果作为命令的一部分进行执行。
这么说可能有些抽象,举个例子,假如没有 incrby 命令,要使用事务保证原子性,需要这样执行:
sql
WATCH counter
GET counter
MULTI
SET counter <the value obtained from GET + any increment>
EXEC
即在事务开始前,先获取事务依赖的操作结果,然后执行事务。那要保证线程安全,就必须引入乐观锁机制,使用 WATCH 命令,而当多个客户端都并发执行这段逻辑时,事务执行失败的概率就非常高,容易不断再次 get ,再次执行事务,再次事务被放弃,陷入循环。
Lua 脚本简介
Redis可以保证脚本内的命令一次性、按顺序地执行,其同时也不提供事务运行错误的回滚,执行过程中如果部分命令运行错误,剩下的命令还是会继续运行完。
Lua 脚本的基本使用
执行脚本:EVAL
css
# [script] 是要执行的脚本,用""包裹
# [numkeys] 代表 key 的数量
# [key ...] key 的名称 [numkeys]个 Lua操作的 Key 的名称必须来自于此。
# [arg ...] 其它参数
EVAL "[script]" [numkeys] [key ...] [arg ...]
EVAL 不仅仅会执行脚本,还会载入脚本,后续会介绍 Redis 的 Lua 缓存
Lua 操作 Key:必须来自 KEYS
Lua 脚本操作的 Key 的来源,必须来自于 KEYS[x]
arduino
EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"
Lua 脚本执行 redis 命令:redis.call
arduino
EVAL "return redis.call('GET','mystring')" 0
"hello world"
redis.call
如果发生错误,会立即返回错误,并终止 Lua 脚本的执行
不同的错误处理:redis.pcall
redis.pcall
和 redis.call
唯一的区别是出错时的处理。
redis.pcall
并不会终止,而是返回一个带err域的Lua表?
脚本缓存:SHA1
单纯的 EVAL 命令存在一个问题,即每次都需要将完整的 Lua 脚本传送给 Redis。
为了解决这个问题,Redis 专门有一个缓存区,存放 Lua 脚本的内容以及 SHA1的值。
EVAL 命令既会执行命令,也会载入缓存,SCRIPT LOAD
是纯粹的载入缓存,在 Redis 持有缓存后,只需要EVALSHA
命令,即可不传输完整的 Lua 脚本,只传输 SHA1 的值来执行脚本。
bash
# 加载脚本
redis> SCRIPT LOAD "return 'Immabe a cached script'"
"c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f"
# 执行脚本
redis> EVALSHA c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f 0
"Immabe a cached script"
脚本缓存本身不会持久化,重启 Redis,主从故障转移等等情况,脚本缓存都会丢失,此时 EVALSHA
命令会返回
go
(error) NOSCRIPT No matching script
在客户端层面需要对缓存丢失的情况做进一步保证,如 EVALSHA
报错后执行 EVAL
命令 。
Lua 常见命令
载入并执行脚本:EVAL
css
# [script] 是要执行的脚本,用""包裹
# [numkeys] 代表 key 的数量
# [key ...] key 的名称 [numkeys]个 Lua操作的 Key 的名称必须来自于此。
# [arg ...] 其它参数
EVAL "[script]" [numkeys] [key ...] [arg ...]
执行载入的脚本:EVALSHA
EVALSHA sha1
查询脚本是否已载入:EXISTS
ini
SCRIPT EXISTS [SHA1 ...]
# 0 不存在 1 存在
载入脚本:LOAD
bash
# 不会执行脚本
SCRIPT LOAD
清除载入的脚本:FLUSH
SCRIPT FLUSH
终止脚本的执行:SCRIPT KILL
bash
# 一旦 lua 脚本存在写入操作,就无法终止
SCRIPT KILL
bash
#!lua flags=no-writes,allow-stale
local x = redis.call('get','x')
return x
Redis 7.0 新特性:Eval flags
Redis 7.0 前,Redis 假设所有的脚本都会进行数据的读和写操作。但 Redis 7.0 提供了 Eval flags
可以告知 Redis 脚本的行为。
只要 Redis 发现
#!
,就会获得一些默认的 lua flags,这和没有#!
的 Lua 脚本不同
没有 #!
的 Lua 脚本可以访问集群中的所有节点,但是由于 #!
默认继承了一些flags,所以不能。
Redis 默认脚本有如下特性:
- 既有读操作,又有写操作
- 可以运行在集群模式下,但不能跨 slots 访问数据
- 从节点数据可能过时,会被拒绝操作从节点
- 内存空间小时,会拒绝执行来避免内存溢出
下面列出部分参数及含义:
参数 | 含义 |
---|---|
allow-cross-slot-keys | 允许脚本访问来自不同slots的keys(尽管应该尽量避免这样做) |
no-cluster | 如果Redis在集群模式下运行,执行脚本会返回错误 |
no-writes | 脚本只有读操作,没有写操作 |
执行时长限制
lua 脚本如果执行时长超过 5 s,Redis 会自动终止该脚本
主从模式下 Lua 的数据同步
Redis 7.0 只有一种同步方案:Effects replication。
主节点会将 Lua 脚本中,实际修改数据的命令封装成「MULTI/EXEC 的事务」并同步给从节点(以及AOF)。
从节点无需将这些命令当作脚本执行,便可以保证主从节点的数据一致。
在 Redis 5.0 之后默认的方案即为 Effects replication。
另一个方案叫 Verbatim replication,需要从节点与主节点完全执行相同的工作,在 Redis 7.0 被废弃。
Lua vs 事务
最后,我们来对比 Lua 和 事务的主要区别。
性能比较
根据官方文档,Lua 的理论效率要高于事务,但实际运行过程中也可能比较接近。Lua 略胜。
使用成本
尽管 Redis 事务相关的命令非常少,但 Lua 脚本的编写也十分简单,入门门槛低。Redis 事务略胜,但不关键。
应用场景
Lua 更灵活,在 Lua 脚本内,可以通过 if then 语句做逻辑控制,而事务不行。
另外一旦事务需要依赖于一个 get 操作的结果,Redis 事务就很难做到了。在灵活度上 Lua 完胜。
什么时候该用事务
总结是,能用 Lua ,就用 Lua,实在用不了才用事务。
- 您的事务所依赖的键不会被频繁修改,这意味着您确信乐观锁定几乎永远不会中止事务。
- 可能是,第三方服务编写的大量逻辑,因此没有简单的方法可以将该逻辑移动到 Lua 脚本。
除非这两点同时满足,否则都更推荐使用 Lua。
参考文档
《Redis 核心技术与实战》
You Don't Need Transaction Rollbacks in Redis
Error: crossslot keys in request don't hash to the same slot