53、Redis Lua脚本:EVAL/EVALSHA、脚本缓存、原子性操作、C语言调用Lua脚本
各位好,今天我们来聊聊Redis里一个非常实用的功能——Lua脚本。说实话,我刚开始接触Redis的时候,觉得它就是个快一点的缓存,存个key-value,顶多用个列表、集合啥的。直到有一次,我在做一个秒杀系统,遇到了“超卖”问题,才真正意识到Lua脚本的价值。
那会儿我用的方案是先查库存,再扣减,中间隔了个网络请求。高并发下,两个请求同时读到库存为1,都去扣减,结果库存变成-1了。嗯,这就是典型的竞态条件。后来我改用Redis的Lua脚本,把查库存和扣库存写在一个脚本里,一次性发给Redis执行,问题就解决了。
说白了,Redis的Lua脚本能保证原子性。脚本执行期间,其他命令插不进来。这个特性,在需要“读-改-写”三步操作的场景下,简直是救星。
EVAL和EVALSHA:两种调用方式
Redis提供了两个命令来执行Lua脚本:EVAL和EVALSHA。它们的作用是一样的,但调用方式不同。
EVAL命令直接把脚本内容发过去。比如:
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
这里KEYS[1]就是传入的键名mykey,ARGV[1]可以用来传参数。注意,Redis要求你显式声明键的数量(上面例子中的1),这是为了后续的集群模式做准备。
EVALSHA则不同。它传的是脚本的SHA1哈希值。Redis会把脚本缓存起来,你第一次用EVAL执行后,脚本就留在缓存里了。之后用EVALSHA加上哈希值,就能直接执行,省去了传输脚本内容的开销。
我个人习惯的做法是:先尝试EVALSHA,如果返回NOSCRIPT错误,再回退到EVAL。这样既享受了缓存带来的性能提升,又保证了兼容性。
脚本缓存:Redis不会丢脚本吗?
这个问题我当初也困惑过。Redis的脚本缓存是存在内存里的,如果Redis重启,缓存就清空了。这时候你再发EVALSHA,Redis会告诉你“我没这个脚本”。
所以,生产环境里,我建议把脚本的源码和SHA1值都存下来。启动时,用SCRIPT LOAD命令把脚本预加载到Redis里。这样,后续所有操作都可以放心用EVALSHA。
举个例子:
// 预加载脚本
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
// 返回: "4e6d...(SHA1值)"
// 之后直接用SHA1调用
EVALSHA 4e6d... 1 mykey
还有一个命令SCRIPT EXISTS,可以检查脚本是否还在缓存里。不过说实话,我很少用它。我更倾向于在应用启动时统一加载,出了问题就重新加载一次。
原子性操作:为什么Lua脚本能保证?
Redis是单线程处理命令的。当它执行一个Lua脚本时,整个脚本会作为一个整体,中间不会被其他命令打断。这就是原子性的来源。
但这里有个坑——脚本里不要做耗时操作。比如循环一万次、调用redis.log打印大量日志,这些都会阻塞Redis。我曾经见过一个同事,在脚本里写了while true,结果整个Redis实例卡死了,所有读写请求都超时。嗯,那场面,真是“一人写脚本,全组跟着跑”。
C语言调用Lua脚本:实战中的做法
在C语言里调用Redis的Lua脚本,通常是通过Redis的客户端库,比如hiredis。hiredis是Redis官方推荐的C语言客户端,轻量、高效。
下面是一个完整的例子:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <hiredis/hiredis.h>
int main() {
// 连接Redis
redisContext *c = redisConnect("127.0.0.1", 6379);
if (c == NULL || c->err) {
if (c) {
printf("连接错误: %s\n", c->errstr);
redisFree(c);
} else {
printf("无法分配redis上下文\n");
}
exit(1);
}
// Lua脚本:原子性地扣减库存
const char *lua_script =
"local stock = redis.call('GET', KEYS[1])\n"
"if not stock then\n"
" return -1 -- 键不存在\n"
"end\n"
"if tonumber(stock) < tonumber(ARGV[1]) then\n"
" return 0 -- 库存不足\n"
"end\n"
"redis.call('DECRBY', KEYS[1], ARGV[1])\n"
"return 1 -- 扣减成功";
// 使用EVAL执行脚本
redisReply *reply = redisCommand(c,
"EVAL %s 1 stock_key 1", lua_script);
if (reply == NULL) {
printf("执行失败\n");
redisFree(c);
exit(1);
}
if (reply->type == REDIS_REPLY_INTEGER) {
printf("结果: %lld\n", reply->integer);
if (reply->integer == 1) {
printf("库存扣减成功!\n");
} else if (reply->integer == 0) {
printf("库存不足\n");
} else {
printf("键不存在\n");
}
}
freeReplyObject(reply);
redisFree(c);
return 0;
}
这段代码里,我用了redisCommand直接发送EVAL命令。实际项目中,我通常会先尝试EVALSHA,如果失败再回退到EVAL。这样能减少网络传输量。
另外,注意脚本里的KEYS[1]和ARGV[1]。Redis要求键名必须通过KEYS数组传入,不能硬编码在脚本里。这是为了在集群模式下,Redis能知道脚本操作了哪些键,从而把脚本路由到正确的节点。
知识结构图
下面这张图,是我自己总结的Redis Lua脚本核心知识结构。你可以把它当作一个速查地图。
避坑指南
最后,分享几个我踩过的坑:
- 脚本里不要用全局变量。Lua脚本在Redis里执行时,每次调用都会创建一个新的环境。全局变量不会跨调用保留。如果你需要状态,用Redis的键来存。
- 小心
redis.call和redis.pcall的区别。redis.call遇到错误会直接抛出异常,中断脚本。redis.pcall会捕获错误,返回一个包含错误信息的表。我一般用redis.pcall,然后自己处理错误。 - 脚本里不要依赖外部状态。比如系统时间、随机数。Redis的Lua脚本是确定性的,同一个脚本、同样的输入,必须产生同样的输出。这是为了主从复制和数据一致性。
- 我曾经在脚本里用了
math.random,结果主从库的随机数不一样,数据对不上了。后来改成用Redis的SRANDMEMBER命令来获取随机元素。
redis.log(redis.LOG_WARNING, "调试信息")在Redis日志里输出内容。不过生产环境记得关掉,否则日志会爆炸。
好了,关于Redis Lua脚本的核心内容,就聊到这里。记住,原子性、脚本缓存、C语言调用,这三个点掌握了,大部分场景都能应对。下次遇到需要“读-改-写”原子操作的场景,不妨试试用Lua脚本解决。
公众号:蓝海资料掘金营,微信deep3321