Redis事务和Lua

这一篇是对在公司内缓存代码应用 Redis-Lua 的一个总结,经过 benchmark 测试,这种方式效率更高,且理论上有更低的可能性。
顺便,一开始先描述一下Redis中的事务的原理,因为Redis-Lua本身是事务的一个替代品,这二者一般放在一起讨论。

Redis中的事务

Redis 事务的特征

和众多其它数据库一样,Redis 作为 NoSQL 数据库也同样提供了事务机制。在 Redis 中,MULTI/EXEC/DISCARD/WATCH 这四个命令是我们实现事务的基石。相信对有关系型数据库开发经验的开发者而言这一概念并不陌生,即便如此,我们还是会简要的列出
Redis 中事务的实现特征:

  1. 在事务中的所有命令都将会被串行化的顺序执行,事务执行期间,Redis 不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行。
  2. 和关系型数据库中的事务相比,在 Redis 事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行。
  3. 我们可以通过 MULTI 命令开启一个事务,有关系型数据库开发经验的人可以将其理解为”BEGIN TRANSACTION”语句。在该语句之后执行的命令都将被视为事务之内的操作,最后我们可以通过执行 EXEC/DISCARD 命令来提交/回滚该事务内的所有操作。这两个 Redis 命令可被视为等同于关系型数据库中的 COMMIT/ROLLBACK 语句。
  4. 在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行 EXEC 命令之后,那么该事务中的所有命令都会被服务器执行。
  5. 当使用 Append-Only 模式时,Redis 会通过调用系统函数 write 将该事务内的所有写操作在本次调用中全部写入磁盘。然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。

Redis 服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用 Redis 工具包中提供的 redis-check-aof 工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动 Redis 服务器了。

WATCH 命令和基于 CAS(Check-And-Set)的乐观锁

在 Redis 的事务中,WATCH 命令可用于提供 CAS(check-and-set)功能。假设我们通过 WATCH 命令在事务执行之前监控了多个 Keys,倘若在 WATCH 之后有任何 Key 的值发生了变化,EXEC 命令执行的事务都将被放弃,同时返回 Null multi-bulk 应答以通知调用者事务执行失败。例如,我们再次假设 Redis 中并未提供 incr 命令来完成键值的原子性递增,如果要实现该功能,我们只能自行编写相应的代码。其伪码如下:

1
2
3
val = GET mykey
val = val + 1
SET mykey $val

以上代码只有在单连接的情况下才可以保证执行结果是正确的,因为如果在同一时刻有多个客户端在同时执行该段代码,那么就会出现多线程程序中经常出现的一种错误场景–竞态争用(race condition)。比如,客户端 A 和 B 都在同一时刻读取了 mykey 的原有值,假设该值为 10,此后两个客户端又均将该值加一后 set 回 Redis 服务器,这样就会导致 mykey 的结果为 11,而不是我们认为的 12。为了解决类似的问题,我们需要借助 WATCH 命令的帮助,见如下代码:

1
2
3
4
5
6
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

和此前代码不同的是,新代码在获取 mykey 的值之前先通过 WATCH 命令监控了该键,此后又将 set 命令包围在事务中,这样就可以有效的保证每个连接在执行 EXEC 之前,如果当前连接获取的 mykey 的值被其它连接的客户端修改,那么当前连接的 EXEC 命令将执行失败。这样调用者在判断返回值后就可以获悉 val 是否被重新设置成功。

Lua 基础

Variables

Control

Funtions

Tables

Modules

Redis Lua 原理

为什么使用 Lua 脚本

  • 减少网络开销
    本来 5 次网络请求的操作,可以用一个请求完成,原先 5 次请求的逻辑放在 redis 服务器上完成。使用脚本,减少了网络往返时延。
  • 原子操作
    Redis 会将整个脚本作为一个整体执行,中间不会被其他命令插入。
  • 复用
    客户端发送的脚本会永久存储在 Redis 中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。

怎么使用 Lua 脚本

大部分人拒绝使用 Lua 应该都是因为它难以维护,容易掉坑,到时候出现线上问题都不好回滚。
退一步讲,其实可以增加一步降级,Lua 脚本出问题时切换到更稳定的一个实现,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try {
if (isScriptValid()) {
redisClient.evalsha(SHA, ScriptOutputType.INTEGER,
new String[0], arg);
return ;
}
} catch (Throwable e) {
Metrics.meter("xxx luaScript error").get().mark();
logger.warn("xxx luaScript error", e);
}
try {
redisClient.set("key1", "value1");
redisClient.set("key2", "value2");
} catch (Throwable e) {
Metrics.meter("xxx redis error").get().mark();
logger.warn("xxx redis error", e);
}

开发时对 Lua 脚本的调试也有一点麻烦,无法通过 Arthus 之类的工具进行排查,下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
huanggaochi$ redis-cli --ldb --eval test.lua
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
Lua debugging session started, please use:
quit -- End the session.
restart -- Restart the script in debug mode again.
help -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1 local foo = redis.call("ping")
lua debugger> s
<redis> ping
<reply> "+PONG"
* Stopped at 2, stop reason = step over
-> 2 return foo
lua debugger> quit

默认--ldb不会阻塞 Redis 服务器,可以执行其他命令或调试其他脚本,调试进程中的所有更改均会回退。
最好不要用--ldb-sync-mode,这种模式会阻塞服务器,该模式下产生的变化将被保留。

用 debugger 调试脚本时,传参容易出错,使用逗号’,’分隔 KEYS 和 ARGV 时,注意逗号两边都是有空格的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bdenmudeMacBook-Pro:~ huanggaochi$ redis-cli -h 10.32.64.19 -a q0QWFyMvT0 --ldb --eval test.lua 1 , 2 3
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
Lua debugging session started, please use:
quit -- End the session.
restart -- Restart the script in debug mode again.
help -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1 local test = ARGV[1]
lua debugger> n
* Stopped at 2, stop reason = step over
-> 2 return test
lua debugger> print
<value> test = "2"
lua debugger> n

"2"

(Lua debugging session ended -- dataset changes rolled back)

类型转换

lua 是弱类型的,但是调用 Redis 命令的时候必须严格规定类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local bv = false;
print(tostring(bv)); -- 输出"false"

local num1 = 10;
local num2 = 10.0;
local num3 = 10.03;
print(tostring(num1)); --输出"10"
print(tostring(num2)); --输出"10"
print(tostring(num3)); --输出"10.03"

local t = {x = 10,y = 0};
print(tostring(t)); -- 输出nil,不能将表类型转换为字符串

local num = tonumber("10"); -- 返回十进制数10
local num = tonumber("AF",16); -- 返回十六进制数175
local num = tonumber("0xA"); -- 返回10
local num = tonumber("56.9"); -- 返回56.9
local num = tonumber("0102"); -- 返回102
local num = tonumber("123456red"); -- 返回nil
local num = tonumber("red"); -- 返回nil
local num = tonumber("true"); -- 返回nil
local num = tonumber({x =10, y = 20});-- 返回nil

字符串分割

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
local function split(str, delimiter)
local dLen = string.len(delimiter)
local newDeli = ''
-- 仅考虑使用单个字符作为分割符,如果传进来一个长度大于1的字符串,则每个字符都算作一个分隔符
for i = 1, dLen, 1 do
newDeli = newDeli .. "[" .. string.sub(delimiter, i, i) .. "]"
end

-- 找到第一次匹配的位置区间,比如find("a,b,c", "[,]"),返回2, 2
local locaStart, locaEnd = string.find(str, newDeli)
local arr = {}
local n = 1
while locaStart ~= nil
do
if locaStart > 0 then
-- 从字符串起始到分隔符区间的前一位
arr[n] = string.sub(str, 1, locaStart - 1)
n = n + 1
end
-- 把从分隔符区间的后一位到字符串末尾截出来作为新串
str = string.sub(str, locaEnd + 1, string.len(str))
-- 从新串中找下一个匹配的分隔符区间
locaStart, locaEnd = string.find(str, newDeli)
end
if str ~= nil then
arr[n] = str
end
return arr
end

直接在命令行执行 Lua 脚本

1
2
3
eval "return _VERSION" 0
eval "return {KEYS[1], ARGV[1]}" 1 a, b
eval "return {ARGV[1]}" 0 b

生成命令执行错误报告

执行命令有两个接口:callpcall,他们的区别是:

  • call 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因。
  • pcall 出错时不引发raise错误,而是用一个 Lua 表包装错误,命令本身返回 nil,后面的命令仍然可以执行成功,最终 pcall 会将捕获的错误以 Lua 表的形式返回;
    1
    2
    redis.pcall("set", "lua_test", "1")
    redis.pcall("smembers", "lua_test")

批量 API

有一些 Redis 函数是支持批量操作的,可以通过一个变长数组参数来处理多个值,比如:

1
redis.pcall("sadd", "lua_test", "a", "b")

但是应用提交到 Redis 服务器的数据,在 Lua 中叫做table,Lua 显然没有提供table和变长数组的兼容机制,直接像下面这样调用是不行的:

1
2
local arr = ARGV[1]
redis.pcall("sadd", "lua_test", arr)

lua 提供了一个函数来做这个拆封操作:

1
2
3
4
5
6
7
if (_VERSION == "Lua 5.1") then
-- Lua 5.1 unpack(test)
redis.pcall("sadd", key, unpack(values))
else
-- Lua 5.2 table.unpack(test)
redis.pcall("sadd", key, table.unpack(values))
end

限流(访问频率控制)

1
2
3
4
5
6
7
8
9
10
11
12
local key = KEYS[1]
local expire = ARGV[1]
local limit = ARGV[2]

local times = redis.call("incr", key)
if times == 1 then
redis.call("expire", key, expire)
end
if times > tonumber(limit) then
return 0
end
return 1

分布式锁

代码中的加锁逻辑大概如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
String lockId = "";
try {
// 并发控制
if (StringUtils.isBlank(lockId = lock(info))) {
throw new BizException("该数据已被其他人修改,请刷新后重试");
}
// 业务逻辑
} finally {
if (StringUtils.isNotBlank(lockId)) {
unlock(info, lockId);
}
}

加锁操作需要生成一个全局唯一的 lockId:

1
2
3
4
5
6
7
8
private String lock(String key) {
// lockId是全局唯一的,如果为了简单可以直接用UUID生成
String lockId = genLockId();
SetArgs setArgs = new SetArgs()
.nx()
.ex(LOCK_EXPIRE);
String result = syncRedisClient.set(key, lockId, setArgs);
}

加锁成功后接下来就是适时地释放掉了,释放操作需要判断值 lockId 是否相等,因为是一个复合操作、最好通过 Lua 脚本的方式实现:

1
2
3
4
5
6
7
local key = ARGV[1]
local lockId = ARGV[2]

local existed = redis.pcall("get", key)
if (existed == lockId) then
redis.pcall("del", key)
end

既然不能把别人的锁抢了,那会不会有一直把锁占着的情况?不会,因为加锁时已经为 key 设置了过期时间。

批量操作

在 Redis 单线程模型的背景下,Lua 脚本的执行是串行化的,执行 Lua 时不能同时执行其他命令或脚本,如果 Lua 脚本非常耗时会导致很多客户端被阻塞,因此,大批量的操作不适合通过 Lua 执行,一种替代方法是pipeline

1
2
3
4
5
6
7
8
9
10
# command.txt中是一大堆set命令,比如:
# set k0 v0
# set k1 v1
# ...
# set k100000 v100000

$ cat command.txt | redis-cli -h 127.0.0.1 -p 6379 -n 0 --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 11

参考

Lua

  1. Learn X in Y minutes
  2. Lua short reference

Redis Lua

  1. redis lua 库积累
  2. Redis Lua 脚本调试器用法说明
  3. Redis 作者 antirez 演示如何使用 Lua 调试器(Redis 3.2 版本新功能)
  4. Redis Lua scripts debugger