Redis 本身已經提供了豐富的命令,但是直接用來處理一些複雜業務時可能還不夠方便,會有一定的侷限性。因此,在 Redis2.6 版本開始提供了對 Lua 腳本的支持,Lua 腳本的使用還是比較廣泛的,比如商品秒殺、分佈式鎖等,使用 Lua 腳本可以帶來以下的好處:
-
原子性
,Redis 會將 Lua 腳本當做一個整體去執行,無需額外開啓事務,可以在高併發場景下保持數據的一致性。 -
複用
,編寫好的 Lua 腳本可以保存在本地,然後發送到 Redis 服務器執行;還可以把腳本緩存到 Redis 服務器,會返回腳本內容的 SHA1 校驗和的編碼,然後通過編碼調用腳本,這裏後邊會詳細介紹。 -
減少網絡開銷
,如果腳本內容很多,還是建議將腳本緩存到服務器,避免直接從本地發送腳本到服務器時在網絡傳輸上造成性能損失。
一、基本用法
爲了讓例子更加的貼近實際應用,這裏實現一個簡單版的分佈式鎖。這裏先用Jedis
操作。
public class LuaService {
public void test1() {
JedisPool jedisPool = new JedisPool("localhost", 6379);
Jedis jedis = jedisPool.getResource();
jedis.auth("shehuan");
try {
// 1、獲取一個隨機字串
String value = UUID.randomUUID().toString();
// 2、獲取鎖
// 爲了防止獲取鎖和設置鎖的過期時間之間,由於某種原因 Redis 掛了,沒成功設置過期時間,導致鎖不能被釋放,
// 需要讓 setnx、expire 命令一起執行,保證原子性
String lock = jedis.set("lock", value, new SetParams().nx().ex(5L));
// 3、判斷是否成功獲取到鎖
if (lock != null && "OK".equals(lock)) {
// 4、獲取到鎖了,獲取到鎖執行具體的業務,例如修改庫存
jedis.set("stock", "1000");
// 5、釋放鎖
// 簡單粗暴的方式是直接 jedis.del("lock")
// 但這樣這可能會有問題,如果第一個線程獲取鎖後執行的業務比較耗時,在到達鎖的過期時間後還沒執行完,但此時鎖已經釋放,
// 此時,第二個線程獲取到了鎖,在第二個線程執行業務的過程中,第一個線程的業務執行結束了,它去把鎖釋放掉了,
// 但第一個線程釋放掉的是第二個線程獲取到的鎖,造成錯誤釋放,這樣亂套了,
// 所以需要保證每個線程只能釋放自己獲得的鎖,不能釋放別人的, 釋放鎖的操作就可以通過 Lua 腳本實現
// todo
} else {
// 6、沒獲取到鎖,可以停止操作,或稍後再繼續嘗試
System.out.println("沒獲取到鎖");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis.close();
}
}
}
上邊詳細的介紹了分佈式鎖的實現過程,以及可能出現的問題,最終,我們決定刪除鎖的操作使用 Lua 腳本實現,對應的腳本如下:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Lua 腳本中執行具體的 Redis 命令,需要使用redis.call()
方法,KEYS
表示客戶端發起腳本執行命令時攜帶的 Redis key 的一個集合,ARGV
則是其它參數的一個集合,主意下標從1開始。結合我們的業務,這裏的KEYS[1]
則表示lock
,ARGV[1]
則是一個隨機字符串。整個腳本的含義就是,如果客戶端傳遞的lock
的value
和 Redis 中存儲的一致,就刪除lock
。
Lua 腳本的語法還是比較簡單的,具體內容可以自行學習。
前邊的準備工作基本結束了,文章開始說過執行腳本有兩種途徑,下邊我們具體來看:
1、客戶端直接發送腳本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList("lock"), Collections.singletonList(value));
if (result != null && (Long) result != 0) {
System.out.println("成功釋放鎖");
}
這裏使用jedis.eval()
發送腳本到 Redis 服務器執行,後兩個參數分別是 key 的集合,以及 value 參數的集合。
2、Redis 服務器緩存腳本
先將腳本以文件形式放到 Redis 裏,例如這樣:
然後通過如下命令讓 Redis 服務器緩存腳本:
redis-cli -x -a 連接redis的密碼 script load < xxx.lua
script load
命令會在 Redis 服務器緩存 Lua 腳本,並且腳本內容經過 SHA-1 簽名算法處理後,會返回腳本內容的 SHA1 校驗和的編碼,然後在端調用時,傳入編碼字符串作爲參數,這樣 Redis 服務器就會執行對應緩存的腳本了,就不用了每次發送具體的腳本內容了。
Object result = jedis.evalsha("e58196026fa94e1c49d14a835c57e941f9ae4202", Collections.singletonList("k1"), Collections.singletonList(value));
if ((Long) result != 0) {
System.out.println("成功釋放鎖");
}
還有兩個比較有用的命令:
-
script exists
使用 sha1 判斷是否存在對應的緩存腳本; -
script flush
刪除全部的緩存腳本。
除了使用上邊的命令緩存腳本、生成腳本的 SHA1 校驗和的編碼,還可以使用 Jedis 實現,但最終的 SHA1 編碼內容是不同的:
String sha1 = jedis.scriptLoad(script);
二、在 SpringBoot 中使用
實際的項目中,可能更多的會在 SpringBoot 項目中整合 Redis,此時執行 Lua 腳本的基本流程如下:
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 設置腳本內容
redisScript.setScriptText(script);
// 設置腳本返回值類型
redisScript.setResultType(Long.class);
// 執行腳本
Long result = stringRedisTemplate.execute(redisScript, Collections.singletonList("lock"), value);
if (result != null && result != 0) {
System.out.println("成功釋放鎖");
}
核心的類就是DefaultRedisScript
,它實現了RedisScript
接口。execute()
方法最後一個參數是可變類型的,用來傳遞多個 value 參數。初次執行execute()
方法時,其內部會自動緩存 Lua 腳本到 Redis 服務器;同時每次執行腳本時會根據腳本內容自動計算出對應的 SHA1 校驗和的編碼,去匹配、執行緩存的腳本。
具體的 SHA1 校驗和的編碼,可以在execute()
方法執行後,使用redisScript.getSha1()
查看。使用 SpringBoot 方式 執行 Lua 腳本生成的 SHA1 校驗和的編碼和前邊直接使用 Jedis 生成的一致。
無論用那種方式在 Redis 中使用 Lua 腳本,其中的原理都是類似的。