一、先啰嗦幾句
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 => {
...
});