Lua + Redis 優惠券領券設計

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腳本

 
  1. @Configuration
  2. public class RedisLuaConfig {
  3. @Bean("luaScript")
  4. public RedisScript<Long> obtainCouponScript() {
  5. DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
  6. redisScript.setLocation(new ClassPathResource("lua/script.lua"));
  7. redisScript.setResultType(Long.class);
  8. return redisScript;
  9. }
  10. }

2. 加載和執行

 
  1. @Slf4j
  2. @Component
  3. public class RedisScriptService {
  4.  
  5. @Autowired
  6. private StringRedisTemplate redisTemplate;
  7. @Resource(name = "luaScript")
  8. private RedisScript<Long> luaScript;
  9.  
  10. /**
  11. * 啓動時加載,手動加載
  12. */
  13. @PostConstruct
  14. public void loadScript() {
  15. redisTemplate.execute(new RedisCallback<String>() {
  16. @Override
  17. public String doInRedis(RedisConnection connection) throws DataAccessException {
  18. StringRedisConnection redisConnection = (StringRedisConnection) connection;
  19. return redisConnection.scriptLoad(luaScript.getScriptAsString());
  20. }
  21. });
  22. }
  23.  
  24. /**
  25. * 執行腳本
  26. * @param keys
  27. * @param args
  28. * @return
  29. */
  30. public int execScript(List<String> keys,List<String> args) {
  31. try {
  32. Long scriptValue = redisTemplate.execute(luaScript,keys,args.toArray());
  33. return scriptValue.intValue();
  34. } catch (Exception e) {
  35. log.error("execute script error", e);
  36. return -1;
  37. }
  38. }
  39. }

屢次加載問題:

  Redis拿到Lua腳本時會先計算其sha1值,sha1值已存在的話會忽略加載,因此當Lua腳本文件內容沒有變化時只會加載一次。

RedisTemplate 執行 RedisScript 對象(Lua腳本)過程:

  •   序列化參數;
  •   RedisScript計算lua腳本 sha1值 (必定和Redis中計算出的sha1值相同);
  •   嘗試使用evalSha 命令執行 Lua腳本;
  •   evalSha失敗時,使用eval 命令執行 Lua腳本;
  •   序列化返回值,返回

執行過程源碼以下:

 
  1. protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
  2. byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {
  3. Object result;
  4. try {
  5. //script.getSha1()方法中會計算sha1值
  6. result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
  7. } catch (Exception e) {
  8. if (!ScriptUtils.exceptionContainsNoScriptError(e)) {
  9. throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
  10. }
  11. //scriptBytes()序列化腳本內容
  12. result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);       // eval方法執行,redis會緩存腳本內容,可是不會記錄其 sha1 值; 下一次evalSha時,redis會表示不認識該sha1值; 因此上面須要手動加載腳本
  13. }
  14. if (script.getResultType() == null) {
  15. return null;
  16. }
  17. return deserializeResult(resultSerializer, result);
  18. }

3. Lua腳本

 
  1. --redis keys
  2. ]; --Lua下表從1開始
  3. ];
  4. ];
  5. --redis args
  6. ]);
  7. ]);
  8. ]);
  9. ];
  10. ];
  11.  
  12. -- 用戶天天可領券的最大數量
  13. local user_today_got = redis.call("hget", user_today_got_key, userId);
  14. if(user_today_got and tonumber(user_today_got) >= user_per_day_max) then
  15. ; --fail
  16. end
  17.  
  18. -- 用戶可領券的最大數量
  19. local user_got = redis.call("hget",user_got_key,couponId);
  20. if(user_got and tonumber(user_got) >= user_max) then
  21. ; --fail
  22. end
  23.  
  24. -- 券的最大數量
  25. local total_got = redis.call("hget",total_got_key,couponId);
  26. if(total_got and tonumber(total_got) >= max) then
  27. ; --fail
  28. end
  29.  
  30. redis.call();
  31. redis.call();
  32. redis.call();
  33. ; -- success

6、不足之處:

  1. 該方案基於單個寫節點的 Redis集羣,沒法適用於多個寫節點的Redis集羣;

  2. Redis 執行 Lua 腳本 具備了原子性, 可是 Lua腳本內的 多個寫操做 沒有實現 原子性(事務)。

7、總結

  經過使用Redis + Lua 方案,解決了領券過程當中的高併發問題。

  優惠券領券數量約束,能夠抽象爲 業務+數量約束,可歸結爲一類問題,相似的業務需求也能夠參考該方案。

 

https://www.jianshu.com/p/119b8014377d

相關文章
相關標籤/搜索