53、Redis Lua脚本:EVAL/EVALSHA、脚本缓存、原子性操作、C语言调用Lua脚本

各位好,今天我们来聊聊Redis里一个非常实用的功能——Lua脚本。说实话,我刚开始接触Redis的时候,觉得它就是个快一点的缓存,存个key-value,顶多用个列表、集合啥的。直到有一次,我在做一个秒杀系统,遇到了“超卖”问题,才真正意识到Lua脚本的价值。

那会儿我用的方案是先查库存,再扣减,中间隔了个网络请求。高并发下,两个请求同时读到库存为1,都去扣减,结果库存变成-1了。嗯,这就是典型的竞态条件。后来我改用Redis的Lua脚本,把查库存和扣库存写在一个脚本里,一次性发给Redis执行,问题就解决了。

说白了,Redis的Lua脚本能保证原子性。脚本执行期间,其他命令插不进来。这个特性,在需要“读-改-写”三步操作的场景下,简直是救星。

EVAL和EVALSHA:两种调用方式

Redis提供了两个命令来执行Lua脚本:EVALEVALSHA。它们的作用是一样的,但调用方式不同。

EVAL命令直接把脚本内容发过去。比如:

EVAL "return redis.call('GET', KEYS[1])" 1 mykey

这里KEYS[1]就是传入的键名mykeyARGV[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实例卡死了,所有读写请求都超时。嗯,那场面,真是“一人写脚本,全组跟着跑”。

警告:Lua脚本默认有5秒的执行超时限制。超过时间,Redis会中断脚本并报错。但注意,中断后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脚本核心知识结构。你可以把它当作一个速查地图。

Redis Lua脚本 调用方式 EVAL(传脚本内容) EVALSHA(传哈希值) 核心特性 原子性执行 脚本缓存(SCRIPT LOAD) C语言调用 hiredis库 redisCommand EVAL/EVALSHA切换 KEYS/ARGV规范 注意:脚本内避免耗时操作,防止阻塞Redis

避坑指南

最后,分享几个我踩过的坑:

  • 脚本里不要用全局变量。Lua脚本在Redis里执行时,每次调用都会创建一个新的环境。全局变量不会跨调用保留。如果你需要状态,用Redis的键来存。
  • 小心redis.callredis.pcall的区别redis.call遇到错误会直接抛出异常,中断脚本。redis.pcall会捕获错误,返回一个包含错误信息的表。我一般用redis.pcall,然后自己处理错误。
  • 脚本里不要依赖外部状态。比如系统时间、随机数。Redis的Lua脚本是确定性的,同一个脚本、同样的输入,必须产生同样的输出。这是为了主从复制和数据一致性。
  • 我曾经在脚本里用了math.random,结果主从库的随机数不一样,数据对不上了。后来改成用Redis的SRANDMEMBER命令来获取随机元素。
小技巧:调试Lua脚本时,可以用redis.log(redis.LOG_WARNING, "调试信息")在Redis日志里输出内容。不过生产环境记得关掉,否则日志会爆炸。

好了,关于Redis Lua脚本的核心内容,就聊到这里。记住,原子性、脚本缓存、C语言调用,这三个点掌握了,大部分场景都能应对。下次遇到需要“读-改-写”原子操作的场景,不妨试试用Lua脚本解决。


公众号:蓝海资料掘金营,微信deep3321