Yufelix's Blog

Yufelix

Redis Lua 脚本:原子性的秘密

8
2024-05-07

大家好!今天我们来聊聊 Redis 的 Lua 脚本。你知道吗?Lua 脚本在 Redis 中可以保证原子性!这是因为 Redis 会把 Lua 脚本当作一个整体来执行,中间不会被打断,也不会被其他客户端的请求干扰。这样一来,Lua 脚本的执行就变得非常可靠。


Redis 事务是什么?

Redis 事务其实就是一组命令的集合。你可以一次性执行多个命令,而且这些命令会作为一个整体被执行,中间不会被其他客户端的请求打断。Redis 事务主要有两个作用:

  1. 保证原子性:要么所有命令都成功执行,要么都不执行。这样可以避免数据出现中间状态,确保一致性。
  2. 提升性能:多个命令可以打包成一个批量操作,一次性发送给 Redis 服务器,减少网络开销。

Redis 事务的执行分为三个阶段:

  • 开始事务:用 MULTI 命令。
  • 命令入队:在 MULTIEXEC 之间添加命令。
  • 执行事务:用 EXEC 命令。

如果你在执行事务前用 WATCH 监视了某个键,那么只有在这个键没有被其他客户端修改的情况下,事务才会执行。否则,事务会被放弃,需要重新开始。

来看个简单的例子:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Hydra
QUEUED
127.0.0.1:6379> set age 18
QUEUED
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
3) (integer) 19

可以看到,所有命令都成功执行了,age 也从 18 变成了 19。


Redis 事务的局限性

虽然 Redis 事务看起来很强大,但它并不完美。我们来看两个常见的错误场景:

  1. 语法错误:如果命令有语法错误,整个事务都不会执行。
  2. 运行时错误:如果某个命令执行失败,其他命令依然会执行,不会回滚。

举个例子:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Hydra
QUEUED
127.0.0.1:6379> incr name  -- 这里会出错,因为 name 不是数字
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range

虽然 incr 命令失败了,但 set name Hydra 依然执行了。这说明 Redis 事务并不支持真正的原子性。


为什么 Redis 不支持回滚?

Redis 官方给出了几个理由:

  1. 错误类型有限:Redis 命令失败通常是因为语法错误或数据类型错误,这些问题应该在开发阶段解决,而不是在生产环境中依赖回滚。
  2. 性能优先:不支持回滚可以让 Redis 的设计更简单,运行更快。
  3. 逻辑错误无法避免:即使支持回滚,也无法解决编程逻辑中的错误。比如,你想把一个值加 2,结果只加了 1,这种问题回滚也帮不上忙。

所以,如果你需要保证原子性,就得自己想办法,比如在事务执行前做参数校验,或者在出错时进行补偿操作。


Lua 脚本入门

既然 Redis 事务有局限性,那我们来看看 Lua 脚本。Lua 脚本在 Redis 中也被当作一条命令执行,因此它天然具备原子性。Redis 从 2.6 版本开始支持 Lua 脚本,下面我们简单了解一下它的用法。

EVAL 命令

EVAL 是执行 Lua 脚本的核心命令,格式如下:

EVAL script numkeys key [key ...] arg [arg ...]
  • script:Lua 脚本代码。
  • numkeys:脚本中用到的 Redis 键的数量。
  • key [key ...]:键名,通过 KEYS[i] 访问。
  • arg [arg ...]:附加参数,通过 ARGV[i] 访问。

举个例子:

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 value1 value2
1) "key1"
2) "key2"
3) "value1"
4) "value2"

SCRIPT LOAD 和 EVALSHA

如果你想复用脚本,可以用 SCRIPT LOAD 把脚本加载到 Redis 中,返回一个 SHA1 校验和。然后用 EVALSHA 执行脚本:

127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1]);"
"228d85f44a89b14a5cdb768a29c4c4d907133f56"

127.0.0.1:6379> EVALSHA "228d85f44a89b14a5cdb768a29c4c4d907133f56" 1 name
"Hydra"

其他命令

  • SCRIPT EXISTS:检查脚本是否被缓存。
  • SCRIPT FLUSH:清除所有缓存的脚本。
  • SCRIPT KILL:终止正在运行的脚本(仅对未执行写操作的脚本有效)。

Lua 脚本的优势

  1. 减少网络开销:多个操作可以在一次请求中完成。
  2. 代码复用:脚本可以缓存,其他客户端可以直接调用。

Java 调用 Lua 脚本

如果你用 Java 操作 Redis,可以通过 DefaultRedisScript 来执行 Lua 脚本。比如:

DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setResultType(Long.class);
script.setScriptSource(new ResourceScriptSource(
    new ClassPathResource("lua/seckill.lua")));

Lua 脚本示例:

local userId = KEYS[1];
local goodsId = KEYS[2];
local cntkey = goodsId .. "_count";
local orderkey = goodsId .. "_" .. userId;

local orderExists = redis.call("get", orderkey);
if (orderExists and tonumber(orderExists) == 1) then
    return 2; -- 用户已下单
end

local num = redis.call("get", cntkey);
if (num and tonumber(num) <= 0) then
    return 0; -- 库存不足
else
    redis.call("decr", cntkey);
    redis.call("set", orderkey, 1);
end
return 1; -- 秒杀成功

总结

Redis 的 Lua 脚本是一个强大的工具,既能保证原子性,又能提升性能。虽然 Redis 事务有一些局限性,但通过 Lua 脚本,我们可以轻松实现复杂的操作逻辑。希望这篇文章能帮到你!如果有问题,欢迎留言讨论~


这样优化后,文章更简洁、易读,同时保留了专业性。希望对你有帮助!