一、先啰嗦几句
redis从2.6版本集成对lua脚本的支持,所以运用redis时,一些复杂的场景可以考虑通过lua脚本来处理,一段lua脚本在执行期具有原子性, 因此可以避免多服务或者并发的很多问题,例如一个杜撰的场景:
设计一个抢红包应用,需要发放10000个红包回馈用户。设计方案是多实例,由redis存储一个计数器,每次用户发起抢红包请求时,首先判断计数器是否已经累计到10000了,是则返回已抢光,否则发放红包(用户抢到红包的信息也存储在redis)。
分析:
该场景下,如果设计方案中用户抢红包的行为不是放入队列的, 而是简单并发, 那么查询redis计数器这一步操作,很可能就会在临界点,例如已经被抢了9999个了,此时两个用户几乎同时抢红包,都查询到还能继续抢,最后发放了两个红包出去。当然,这还是乐观的,实际情况可能是瞬间并发量非常大,导致发放了更多的红包出去。
那么我们希望
查询计数器跟存储抢到红包的信息两个操作是原子性的,redis为我们提供了multi,实际上也是无法做到的,multi只适合可以并行的操作,对于这种需要先执行A再能决定是否执行B的串行操作不适用。
此时lua就可以帮我们忙了,在lua里查询,判断,确定抢到红包完全是一个原子操作,也不需要把抢红包动作设计为一个队列,更不需要去担心并发的影响。
此外,两个操作合并为一个lua脚本去执行时,还节省了一步redis的io耗时。
进一步讲,redis缓存了所有执行过的lua脚本,只要设计得当,这个操作向redis传送脚本的带宽可以节省到一个sha码的大小。
所以lua脚本对于redis来说可以是一把利刃。
对于redis怎么调lua以及lua怎么写不作深入,如果不了解可以学习一下相关的内容。切入正题,nodeJs项目如果有用到redis,并且redis有调用lua脚本的需求,可以参考以下的方案封装:
/**
*
* lua脚本集合
*
* 用于一些对redis原有数据有执行依赖的事务
*
* @author {cmx}
*/
// redis连接,本身不创建,由外部传入。
const redisClient;
let instance = {
script : {
}
};
// 用于记录已在redis缓存过的脚本sha码
let bufferScript = {};
/**
* 抢红包动作的脚本定义(keysLength值为KEY的个数)
*
* KEYS[1] 计数器key
* KEYS[2] 用户已抢到红包key
* ARGV[1] 红包数额
* ARGV[2] limit
*
* @return 1 成功 -1 失败1
*/
instance.script.grabbingRedPacket = {
code : `
if(redis.call('xxx', KEYS[1]) < tonumber(ARGV[2]))
then
if(redis.call('xxx', KEYS[2], ARGV[1]) == 1)
then
return 1
end
else
return -1
end
`,
keysLength : 2
};
/**
*
* lua执行器 自动判断是否已经缓存过 从而决定是向redis传递脚本还是sha
*
* @param name 本脚本所支持的指令 位于 instance.script 下
* @param ...param 该指令所期待的参数, 按照KEYS到ARGV的顺序罗列
*/
instance.run = function(name, ...param) {
return new Promise((resolve, reject) => {
if (!redisClient) {
reject('redisClient is no ready');
} else if (!instance.script[name]) {
reject('this command is not supported');
} else {
if (bufferScript[name]) {
redisClient.evalsha(bufferScript[name], instance.script[name].keysLength, ...param, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
} else {
redisClient.script('load', instance.script[name].code, (err, sha) => {
if (err) {
reject(err);
} else {
bufferScript[name] = sha;
redisClient.evalsha(sha, instance.script[name].keysLength, ...param, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
}
});
}
}
});
}
module.exports = function(client) {
if (!client) {
return;
}
redisClient = client;
return instance;
}
除查询类型的命令外, 增改删类型的命令返回值简单遵循一个规范:
- 成功 返回值大于0
- 失败 返回值小于0
具体成功原因和失败原因由具体的返回值决定。
命令
装载一些项目需要用到的脚本, 全部位于 luaScript.script
下。
执行器
简单封装了一个用于执行脚本的方法 luaScript.run(name, ....param)
, 自动缓存脚本的sha码, 可以确保在服务存活周期内重复执行一段脚本时, 都是采用执行sha码的方案, 而不是每次把脚本完整传送到redis。其中 name
为已支持的脚本命令, 例如有 luaScript.script.grabbingRedPacket
, 那么调用时 name
填入 grabbingRedPacket
。
二、使用方法:
- 引入
const redis = require('redis');
const client = redis.createClient(config.REDIS_PORT, config.REDIS_HOST, config.REDIS_OPTIONS);
// 传入一个redis连接即可
const luaScript = require('xxx/lua-script')(client);
- 调用
// 不需要关心这个脚本keys的数量, luaScript已经帮助实现了这个逻辑, 直接罗列命令期望的参数即可。
luaScript.run('grabbingRedPacket',
counterKey,
userKey,
amount,
limit
).then(res => {
...
});