1、業務背景redis
優惠券業務主要提供用戶領券和消券的功能;領取優惠券的動做由用戶直接發起,因爲資源有限,咱們必須對用戶的領取動做進行一些常規約束。sql
- 約束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
恭喜!成功領到一個新的優惠券!ide
2、數據分析高併發
max,user_max,user_per_day_max 三個值是元數據,基本是靜態值(容許修改);性能
total_got,user_got,user_today_got 三個值是動態值,且屬於三個不一樣維度,不適合做爲一條記錄存在表裏,須要分三個表記錄;lua
用戶領券時,取出這6個值,一個if 把對應值 比較一下,再依次修改一下領取數量的值;
三次讀取,三次比較,三次更新,完工。
3、問題:併發
搶券開始,用戶積極性不錯,不一下子 券就被搶完了,手慢的用戶被告知領券失敗,沒有問題,收工。
回頭看一眼數據,彷佛不太妙,超領了。
併發,萬惡的根源。
用戶張三李四 取出的 total_got 值都同樣,張三能夠領,李四也能夠領,因而,if 條件在這一刻失效,
或者張三 連續來兩次取出的 user_got 值都同樣,因而張三能夠領兩次,因而,if 條件在這一刻失效。
先讀再寫並行,併發問題的根源。
4、解決思路
從讀到寫這段時間的數據不一致問題,根源在於用戶並行(我的認爲併發是時間概念,並行是空間概念),
要解決這個問題,須要讓用戶串行,單個用戶原子性。鎖 說它能夠作到。
鎖只有一個目的,就是把並行變爲串行,可是上鎖的方式 五花八門。
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腳本的邏輯基本爲:
5、業務實現(基於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
- ]; --Lua下表從1開始
- ];
- ];
- --redis args
- ]);
- ]);
- ]);
- ];
- ];
-
- -- 用戶天天可領券的最大數量
- 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
- ; --fail
- end
-
- -- 用戶可領券的最大數量
- local user_got = redis.call("hget",user_got_key,couponId);
- if(user_got and tonumber(user_got) >= user_max) then
- ; --fail
- end
-
- -- 券的最大數量
- local total_got = redis.call("hget",total_got_key,couponId);
- if(total_got and tonumber(total_got) >= max) then
- ; --fail
- end
-
- redis.call();
- redis.call();
- redis.call();
- ; -- success
6、不足之處:
1. 該方案基於單個寫節點的 Redis集羣,沒法適用於多個寫節點的Redis集羣;
2. Redis 執行 Lua 腳本 具備了原子性, 可是 Lua腳本內的 多個寫操做 沒有實現 原子性(事務)。
7、總結
經過使用Redis + Lua 方案,解決了領券過程當中的高併發問題。
優惠券領券數量約束,能夠抽象爲 業務+數量約束,可歸結爲一類問題,相似的業務需求也能夠參考該方案。
https://www.jianshu.com/p/119b8014377d