使用過Redis事務的應該清楚,Redis事務實現是通過打包多條命令,單獨的隔離操作,事務中的所有命令都會按順序地執行。事務在執行的過程中,不會被其他使用者端傳送來的命令請求所打斷。事務中的命令要麼全部被執行,要麼全部都不執行(原子操作)。但其中有命令因業務原因執行失敗並不會阻斷後續命令的執行,且也無法回滾已經執行過的命令。如果想要實現和MySQL一樣的事務處理可以使用Lua指令碼來實現,Lua指令碼中可實現簡單的邏輯判斷,執行中止等操作。
Lua是一個小巧的指令碼語言,Redis 指令碼使用 Lua 直譯器來執行指令碼。 Reids 2.6 版本通過內嵌支援 Lua 環境。執行指令碼的常用命令為 EVAL。編寫Lua指令碼就和編寫shell指令碼一樣的簡單。Lua語言詳細教學參見
範例:
--[[
version:1.0
檢測key是否存在,如果存在並設定過期時間
入參列表:
引數個數量:1
KEYS[1]:goodsKey 商品Key
返回列表code:
+0:不存在
+1:存在
--]]
local usableKey = KEYS[1]
--[ 判斷usableKey在Redis中是否存在 存在將過期時間延長1分鐘 並返回是否存在結果--]
local usableExists = redis.call('EXISTS', usableKey)
if (1 == usableExists) then
redis.call('PEXPIRE', usableKey, 60000)
end
return { usableExists }
經典案例需求:庫存量扣減並檢測庫存量是否充足。
基礎需求分析:商品當前庫存量>=扣減數量時,執行扣減。商品當前庫存量<扣減數量時,返回庫存不足
實現方案分析:
1)MySQL事務實現:
2)方案優缺點分析:
Redis Lua指令碼事務實現:將庫存扣減判斷庫存量最小原子操作邏輯編寫為Lua指令碼。
方案優缺點分析:
初始化商品庫存量:
//利用Watch 命令樂觀樂特性,減少鎖競爭所損耗的效能
public boolean init(InitStockCallback initStockCallback, InitOperationData initOperationData) {
//SessionCallback 對談級Rdis事務回撥介面 針對於operations所有操作將在同一個Redis tcp連線上完成
List<Object> result = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
public List<Object> execute(RedisOperations operations) {
Assert.notNull(operations, "operations must not be null");
//Watch 命令用於監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那麼事務將被打斷
//當出前並行初始化同一個商品庫存量時,只有一個能成功
operations.watch(initOperationData.getWatchKeys());
int initQuantity;
try {
//查詢DB商品庫存量
initQuantity = initStockCallback.getInitQuantity(initOperationData);
} catch (Exception e) {
//異常後釋放watch
operations.unwatch();
throw e;
}
//開啟Reids事務
operations.multi();
//setNx設定商品庫存量
operations.opsForValue().setIfAbsent(initOperationData.getGoodsKey(), String.valueOf(initQuantity));
//設定商品庫存量 key 過期時間
operations.expire(initOperationData.getGoodsKey(), Duration.ofMinutes(60000L));
///執行事事務
return operations.exec();
}
});
//判斷事務執行結果
if (!CollectionUtils.isEmpty(result) && result.get(0) instanceof Boolean) {
return (Boolean) result.get(0);
}
return false;
}
庫存扣減邏輯
--[[
version:1.0
減可用庫存
入參列表:
引數個數量:
KEYS[1]:usableKey 商品可用量Key
KEYS[3]:usableSubtractKey 減量記錄key
KEYS[4]:operateKey 操作防重Key
KEYS[5]:hSetRecord 記錄操作單號資訊
ARGV[1]:quantity運算元量
ARGV[2]:version 操作版本號
ARGV[5]:serialNumber 單據流水編碼
ARGV[6]:record 是否記錄過程量
返回列表:
+1:操作成功
0: 操作失敗
-1: KEY不存在
-2:重複操作
-3: 庫存不足
-4:過期操作
-5:缺量庫存不足
-6:可用負庫存
--]]
local usableKey = KEYS[1];
local usableSubtractKey = KEYS[3]
local operateKey = KEYS[4]
local hSetRecord = KEYS[5]
local quantity = tonumber(ARGV[1])
local version = ARGV[2]
local serialNumber = ARGV[5]
--[ 判斷商品庫存key是否存在 不存在返回-1 --]
local usableExists = redis.call('EXISTS', usableKey);
if (0 == usableExists) then
return { -1, version, 0, 0 };
end
--[ 設定防重key 設定失敗說明操作重複返回-2 --]
local isNotRepeat = redis.call('SETNX', operateKey, version);
if (0 == isNotRepeat) then
redis.call('SET', operateKey, version);
return { -2, version, quantity, 0 };
end
--[ 商品庫存量扣減後小0 說明庫存不足 回滾扣減數量 並清除防重key立即過期 返回-3 --]
local usableResult = redis.call('DECRBY', usableKey, quantity);
if ( usableResult < 0) then
redis.call('INCRBY', usableKey, quantity);
redis.call('PEXPIRE', operateKey, 0);
return { -3, version, 0, usableResult };
end
--[ 記錄扣減量並設定防重key 30天后過期 返回 1--]
-- [ 需要記錄過程量與過程單據資訊 --]
local usableSubtractResult = redis.call('INCRBY', usableSubtractKey, quantity);
redis.call('HSET', hSetRecord, serialNumber, quantity)
redis.call('PEXPIRE', hSetRecord, 3600000)
redis.call('PEXPIRE', operateKey, 2592000000)
redis.call('PEXPIRE', usableKey, 3600000)
return { 1, version, quantity, 0, usableResult ,usableSubtractResult}
初始化Lua指令碼到Redis伺服器
//讀取Lua指令碼檔案
private String readLua(File file) {
StringBuilder sbf = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String temp;
while (Objects.nonNull(temp = reader.readLine())) {
sbf.append(temp);
sbf.append('\n');
}
return sbf.toString();
} catch (FileNotFoundException e) {
LOGGER.error("[{}]檔案不存在", file.getPath());
} catch (IOException e) {
LOGGER.error("[{}]檔案讀取異常", file.getPath());
}
return null;
}
//初始化Lua指令碼到Redis伺服器 成功後會返回指令碼對應的sha1碼,系統快取指令碼sha1碼,
//通過sha1碼可以在Redis伺服器執行對應的指令碼
public String scriptLoad(File file) {
String script = readLua(file)
return stringRedisTemplate.execute((RedisCallback<String>) connection -> connection.scriptLoad(script.getBytes()));
}
指令碼執行
public OperationResult evalSha(String redisScriptSha1,OperationData operationData) {
List<String> keys = operationData.getKeys();
String[] args = operationData.getArgs();
//執行Lua指令碼 keys 為Lua指令碼中使用到的KEYS args為Lua指令碼中使用到的ARGV引數
//如果是在Redis叢集模式下,同一個指令碼中的多個key,要滿足多個key在同一個分片
//伺服器開啟hash tag功能,多個key 使用{}將相同部分包裹
//例:usableKey:{EMG123} operateKey:operate:{EMG123}
Object result = stringRedisTemplate.execute(redisScriptSha1, keys, args);
//解析執行結果
return parseResult(operationData, result);
}
Redis在小資料操作並行可達到10W,針對與業務中對資源強校驗且高並行場景下使用Redis配合Lua指令碼完成簡單邏輯處理抗並行量是個不錯的選擇。
注:Lua指令碼邏輯儘量簡單,Lua指令碼實用於耗時短且原子操作。耗時長影響Redis伺服器效能,非原子操作或邏輯複雜會增加於指令碼偵錯與維度難度。理想狀態是將業務用Lua指令碼包裝成一個如Redis命令一樣的操作。
作者:王純