一、業務背景
優惠券業務主要提供用戶領券和消券的功能;領取優惠券的動作由用戶直接發起,由於資源有限,我們必須對用戶的領取動作進行一些常規約束。
- 約束1(優惠券維度): 券的最大數量 max;
- 約束2(用戶維度): 每個用戶可領取的最大數量 user_max;
為了滿足一些特殊場景,比如連續幾天的大促活動,為了吸引用戶,允許用戶每天領取一次優惠券。於是,
- 約束3(用戶加時間維度): 每個用戶每天可領取的最大數量 user_per_day_max;
目前,用戶領券只有上述三個約束,未來,也許,會有更復雜的約束需求。
為了同時滿足上述三個約束,優惠券業務分別 記錄了 每個用戶當天已領取的數量 user_today_got,每個用戶已領取的數量user_got , 所有用戶領取的數量 total_got,
只要在用戶領券前 以下三個條件成立:
- user_today_got < user_per_day_max
- user_got < user_max
- total_got < max
恭喜!成功領到一個新的優惠券!
二、數據分析
max,user_max,user_per_day_max 三個值是元數據,基本是靜態值(允許修改);
total_got,user_got,user_today_got 三個值是動態值,且屬於三個不同維度,不適合作為一條記錄存在表里,需要分三個表記錄;
用戶領券時,取出這6個值,一個if 把對應值 比較一下,再依次修改一下領取數量的值;
三次讀取,三次比較,三次更新,完工。
三、問題:並發
搶券開始,用戶積極性不錯,不一會兒 券就被搶完了,手慢的用戶被告知領券失敗,沒有問題,收工。
回頭看一眼數據,似乎不太妙,超領了。
並發,萬惡的根源。
用戶張三李四 取出的 total_got 值都一樣,張三可以領,李四也可以領,於是,if 條件在這一刻失效,
或者張三 連續來兩次取出的 user_got 值都一樣,於是張三可以領兩次,於是,if 條件在這一刻失效。
先讀再寫並行,並發問題的根源。
四、解決思路
從讀到寫這段時間的數據不一致問題,根源在於用戶並行(個人認為並發是時間概念,並行是空間概念),
要解決這個問題,需要讓用戶串行,單個用戶原子性。鎖 說它可以做到。
鎖只有一個目的,就是把並行變為串行,但是上鎖的方式 五花八門。
1. Java應用內存鎖
Java中自帶很多內存鎖,synchronize,各種Lock,但是優惠券服務多機部署,內存鎖無法滿足需求;
2. Mysql數據庫鎖
優惠券服務使用MySql(一個寫節點),innodb存儲引擎,innodb 支持 行鎖。
利用innodb的行鎖機制,可以使用兩種方式實現用戶領券的原子性:
第一種,讀取之前上鎖, 更新之后解鎖
select ... from table where ... for update;
update table set ....
優點: 簡單明了; 缺點: select 和 update 之間處理 出異常或應用異常終止 會產生死鎖。
第二中,利用update 鎖行機制,加上where 條件 判斷數據,也是讀取前上鎖,更新后解鎖。
update table set .... where ....
優點:簡單明了; 缺點: 效率不高
另外更新操作直接命中數據庫會對數據庫產生很大的壓力,所以數據庫鎖無法滿足搶券業務;
3. Redis分布式內存鎖
優惠券服務使用單節點Redis,Redis 支持setnx命令。
利用setnx命令,可以在應用中自建鎖及維護鎖的生命周期。
基本思路是領券前將優惠券的key通過 setnx 命令寫進 redis,成功則之后便執行后續的三次讀取 比較 和更新,
最后 del 命令刪除優惠券的key。
優點:邏輯簡單,實現簡單,total_got,user_got,user_today_got 三個值 存哪里不受任何限制。
缺點:不太可靠,setnx 成功后,應用出現異常,沒有執行最后的del , 會產生死鎖;也可以在 setnx 后再
設置一個過期時間,是的,這是一個辦法,只需要保證過期時間大於 接口的最大執行時間。
另外,也可以使用 官方推薦的 分布式Redis鎖 開源實現 Redisson。
3. Redis的 pipeline & lua
Redis 使用單線程處理命令隊列,串行執行每個命令,Redis數據讀寫操作不存在並行。
如果需要修改的數據都存儲在Redis中,那么可以將一批排序的命令發給Redis, Redis命令隊列保證不會打亂你的排序,並且保證不會有人插隊即可。
Redis提供了pipeline的方式一次解析接收多個命令,並且保證不會打亂你的命令順序,但是很可惜,Redis不保證 不會有人插隊,pipeline的設計目的是
為了節約RTT。
優惠券業務需要一系列操作具有原子性,pipeline方式不可行。
Redis 支持執行 Lua 腳本,提供 eval 命令執行Lua腳本,注意,eval是一個命令,Redis單個命令都是原子執行的,執行Lua腳本當然也是原子性的。
Lua腳本可以承載豐富的業務邏輯和Redis數據操作,領券只需要原子性的三次讀取三次比較以及三次更新,Redis + Lua 完全可以勝任,並且提供不錯的性能。
采用Redis + Lua 的解決思路如下:
Lua腳本的邏輯基本為:
五、業務實現(基於Spring)
1. 配置Lua腳本
@Configuration public class RedisLuaConfig { @Bean("luaScript") public RedisScript<Long> obtainCouponScript() { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setLocation(new ClassPathResource("lua/script.lua")); redisScript.setResultType(Long.class); return redisScript; } }
2. 加載和執行
@Slf4j @Component public class RedisScriptService { @Autowired private StringRedisTemplate redisTemplate;
@Resource(name = "luaScript") private RedisScript<Long> luaScript; /** * 啟動時加載,手動加載 */ @PostConstruct public void loadScript() { redisTemplate.execute(new RedisCallback<String>() { @Override public String doInRedis(RedisConnection connection) throws DataAccessException { StringRedisConnection redisConnection = (StringRedisConnection) connection; return redisConnection.scriptLoad(luaScript.getScriptAsString()); } }); } /** * 執行腳本 * @param keys * @param args * @return */ public int execScript(List<String> keys,List<String> args) { try { Long scriptValue = redisTemplate.execute(luaScript,keys,args.toArray()); return scriptValue.intValue(); } catch (Exception e) { log.error("execute script error", e); return -1; } } }
多次加載問題:
Redis拿到Lua腳本時會先計算其sha1值,sha1值已存在的話會忽略加載,所以當Lua腳本文件內容沒有變化時只會加載一次。
RedisTemplate 執行 RedisScript 對象(Lua腳本)過程:
- 序列化參數;
- RedisScript計算lua腳本 sha1值 (一定和Redis中計算出的sha1值相同);
- 嘗試使用evalSha 命令執行 Lua腳本;
- evalSha失敗時,使用eval 命令執行 Lua腳本;
- 序列化返回值,返回
執行過程源碼如下:
protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys, byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) { Object result; try { //script.getSha1()方法中會計算sha1值 result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs); } catch (Exception e) { if (!ScriptUtils.exceptionContainsNoScriptError(e)) { throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e); } //scriptBytes()序列化腳本內容 result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
// eval方法執行,redis會緩存腳本內容,但是不會記錄其 sha1 值; 下一次evalSha時,redis會表示不認識該sha1值; 所以上面需要手動加載腳本 } if (script.getResultType() == null) { return null; } return deserializeResult(resultSerializer, result); }
3. Lua腳本
--redis keys local user_today_got_key = KEYS[1]; --Lua下表從1開始 local user_got_key = KEYS[2]; local total_got_key = KEYS[3]; --redis args local user_per_day_max = tonumber(ARGV[1]); local user_max = tonumber(ARGV[2]); local max = tonumber(ARGV[3]); local userId = ARGV[4]; local couponId = ARGV[5]; -- 用戶每天可領券的最大數量 local user_today_got = redis.call("hget", user_today_got_key, userId); if(user_today_got and tonumber(user_today_got) >= user_per_day_max) then return 1; --fail end -- 用戶可領券的最大數量 local user_got = redis.call("hget",user_got_key,couponId); if(user_got and tonumber(user_got) >= user_max) then return 2; --fail end -- 券的最大數量 local total_got = redis.call("hget",total_got_key,couponId); if(total_got and tonumber(total_got) >= max) then return 3; --fail end redis.call("hincrby",user_today_got_key, userId,1); redis.call("hincrby",user_got_key, couponId,1); redis.call("hincrby",total_got_key, couponId,1); return 0; -- success
六、不足之處:
1. 該方案基於單個寫節點的 Redis集群,無法適用於多個寫節點的Redis集群;
2. Redis 執行 Lua 腳本 具有了原子性, 但是 Lua腳本內的 多個寫操作 沒有實現 原子性(事務)。
七、總結
通過使用Redis + Lua 方案,解決了領券過程中的高並發問題。
優惠券領券數量約束,可以抽象為 業務+數量約束,可歸結為一類問題,類似的業務需求也可以參考該方案。