【5】Redis從入門到放棄---秒殺案例(Redis的事務+鎖機制+lua腳本)


(1)Redis的事務

1.1 Redis事務的定義:

Redis事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端發送來的命令請求所打斷。 Redis事務的主要作用就是串聯多個命令防止別的命令插隊

1.2 Multi、Exec、discard命令

組隊階段:從輸入multi命令開始,后面輸入的任務命令都會依次放入到隊列中,但不會執行;

執行階段:及就是從輸入exec開始,Redis會將之前的命令隊列中的命令依次執行;

取消事務:只能在組隊的過程中可以通過discard命令來放棄組隊。

1.3 實操如下:

 場景一:組隊成功,提交也成功

 場景二:組隊階段報錯,提交失敗

 場景三:組隊成功,提交有成功有失敗情況

 1.4 Redis事務三特性

單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端發送來的命令請求所打斷。

沒有隔離級別的概念:隊列中的命令沒有提交之前都不會實際被執行,因為事務提交前任何指令都不會被實際執行

不保證原子性 :事務中如果有一條命令執行失敗,其后的命令仍然會被執行,沒有回滾

(2)Redis的事務鎖機制

 2.1 Redis的鎖機制

在實際業務中,有一些場景例如:秒殺、搶車票等等,同一時間多個請求進來,那可能就會存在超賣現象,針對這種情況我們可以使用事務和redis的鎖機制來解決這種問題。

樂觀鎖(Optimistic Lock):顧名思義,就是很樂觀,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量。Redis就是利用這種check-and-set機制實現事務的。

悲觀鎖(Pessimistic Lock):顧名思義,就是很悲觀,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會block直到它拿到鎖。傳統的關系型數據庫里邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

2.2 watch和unwatch的命令

watch key [key ...]-----在執行multi之前,先執行watch key1 key2,可以監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那么事務將被打斷。

unwatch 取消 WATCH 命令對所有 key 的監視。 如果在執行 WATCH 命令之后,EXEC 命令或DISCARD 命令先被執行了的話,那么就不需要再執行UNWATCH 了。 

http://doc.redisfans.com/transaction/exec.html

(3)秒殺案例

  1. 使用Redis解決計數器和人員記錄的事務操作
  2. 模擬:單個請求到並發秒殺(使用工具JMeter模擬測試)
  3. 超賣問題:利用事務和樂觀鎖淘汰用戶,解決超賣問題
  4. 模擬:加大庫存,會存在秒殺結束卻還有庫存
  5. 使用LUA腳本解決庫存剩余問題

 

1.使用Redis解決計數器和人員記錄的事務操作

寫個秒殺測試類如下:

/**
 * 秒殺案例,一個用戶只能秒殺成功一次
 */
@RestController
@RequestMapping("/testRedisSeckill")
public class TestRedisSeckillController {

    @Autowired
    private RedisTemplate redisTemplate;


    @GetMapping("/doSeckill")
    public boolean doSeckill() throws IOException {
        String usrId = new Random().nextInt(50000) + "";
        return doSeckillFun(usrId, "20210731");
    }
}
/**
     * 秒殺過程1(高並發下會超賣)
     *
     * @param usrId 用戶id
     * @param atcId 活動id
     * @return
     * @throws IOException
     */
    private boolean doSeckillFun(String usrId, String atcId) throws IOException {
        //1.參數校驗
        if (usrId == null || atcId == null) return false;

        //2.設置Redis值(庫存key== atcId:stock, 秒殺成功用戶key== atcId:userId)
        String stockKey = atcId + ":stock";
        String userIdKey = atcId + ":userId";

        //3.獲取庫存,如果庫存是空,秒殺還沒開始
        Object stock = redisTemplate.opsForValue().get(stockKey);//獲取庫存
        if (stock == null) {
            System.out.println("別着急,秒殺還沒開始呢!!");
            return false;
        }

        //4.判斷用戶是否重復秒殺操作(Set類型操作)
        if (redisTemplate.opsForSet().isMember(userIdKey, usrId)) {
            System.out.println("你已經秒殺成功了,不能重復秒殺");
            return false;
        }

        //5.判斷庫存數量,小於1,秒殺結束
        int stock1 = (int) stock;
        if (stock1 < 1) {
            System.out.println("秒殺結束了。。。");
            return false;
        }

        //6.秒殺過程(庫存減1,把秒殺成功用戶添加到用戶清單)
        redisTemplate.opsForSet().add(userIdKey, usrId);
        redisTemplate.opsForValue().decrement(stockKey);//庫存-1
        System.out.println("恭喜你!秒殺成功了!");
        return true;
    }

1.1 模擬場景1:單個請求

先設置庫存10個

 Jmeter模擬單個請求

 

 查看redis剩余庫存和用戶清單:

 1.2 模擬高並發500個請求

看着沒什么毛病對吧,那如果我把並發加大到500,會出現什么情況呢?

在執行之前先清空redis的數據,點擊jmeter執行

 控制台輸出:

 查看redis數據

 

 發現庫存 -190,出現超賣了,所以我們的場景1的代碼在高並發的情況下會出現超賣的問題,那么針對這個問題我們需要使用樂觀鎖來解決

2.使用樂觀鎖來解決

代碼如下:

/**
     * 秒殺過程2(樂觀鎖解決超賣問題)
     *
     * @param usrId 用戶id
     * @param atcId 活動id
     * @return
     * @throws IOException
     */
    private boolean doSeckillFun(String usrId, String atcId) throws IOException {
        //1.參數校驗
        if (usrId == null || atcId == null) return false;

        //2.設置Redis值(庫存key== atcId:stock, 秒殺成功用戶key== atcId:userId)
        String stockKey = atcId + ":stock";
        String userIdKey = atcId + ":userId";

        //通過 SessionCallback,保證所有的操作都在同一個 Session 中完成
        //更常見的寫法仍是采用 RedisTemplate 的默認配置,即不開啟事務支持。
        // 但是,我們可以通過使用 SessionCallback,該接口保證其內部所有操作都是在同一個Session中
        SessionCallback<Object> callback = new SessionCallback<Object>() {

            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                //3. 打開事務支持
                //redisTemplate.setEnableTransactionSupport(true);

                //4.增加樂觀鎖進行對庫存的監視
                operations.watch(stockKey);

                //5.獲取庫存,如果庫存是空,秒殺還沒開始
                Object stock = operations.opsForValue().get(stockKey);//獲取庫存
                if (stock == null) {
                    System.out.println("別着急,秒殺還沒開始呢!!");
                    return false;
                }

                //6.判斷用戶是否重復秒殺操作(Set類型操作)
                if (operations.opsForSet().isMember(userIdKey, usrId)) {
                    System.out.println("你已經秒殺成功了,不能重復秒殺");
                    return false;
                }

                //7.判斷庫存數量,小於1,秒殺結束
                int stock1 = (int) stock;
                if (stock1 < 1) {
                    System.out.println("秒殺結束了。。。");
                    return false;
                }

                //8. 增加事務
                operations.multi();

                //9.秒殺過程
                operations.opsForValue().decrement(stockKey);//庫存-1
                operations.opsForSet().add(userIdKey, usrId);//把秒殺成功用戶添加到用戶清單

                //10.執行事務
                List<Object> list = operations.exec();

                //11.判斷事務提交是否失敗
                if (list == null || list.size() == 0) {
                    System.out.println("秒殺失敗");
                    return false;
                }

                System.out.println("恭喜你!秒殺成功了!");
                return true;
            }
        };

        return (boolean) redisTemplate.execute(callback);
    }

 2.1 設置10個庫存,繼續模擬500個並發請求,結果如下:

 

 終於解決超賣問題了,嘻嘻

 2.2 那我把庫存加大到300個,繼續模擬500個並發請求,會出現什么情況呢?

 執行Jmeter模擬500個並發,結果如下:

 

 雖然沒有超賣問題了,但是有500個請求卻還剩余102個庫存,那么就有下邊的lua解決庫存遺留問題。

 

3. LUA腳本在Redis中的優勢 :將復雜的或者多步的redis操作,寫為一個腳本,一次提交給redis執行,減少反復連接redis的次數。提升性能。 LUA腳本是類似redis事務,有一定的原子性,不會被其他命令插隊,可以完成一些redis事務性的操作。 但是注意redis的lua腳本功能,只有在Redis 2.6 以上的版本才可以使用。 利用lua腳本淘汰用戶,解決超賣問題。 redis 2.6版本以后,通過lua腳本解決爭搶問題,實際上是redis 利用其單線程的特性,用任務隊列的方式解決多任務並發問題。

使用Lua腳本解決庫存遺留的問題,代碼如下:

 /**
     * 秒殺過程3(LUA解決庫存剩余問題)
     *
     * @param usrId 用戶id
     * @param atcId 活動id
     * @return
     * @throws IOException
     */
    private boolean doSeckillFun(String usrId, String atcId) throws IOException {

        String luaScript = "local userId=KEYS[1];\r\n" +
                "local stockKey=KEYS[2];\r\n" +
                "local userIdKey=KEYS[3];\r\n" +
                "local userExists=redis.call(\"sismember\",userIdKey,userId); \r\n" +
                "if tonumber(userExists)==1 \r\n" +
                "then \r\n" +
                "  return 2;\r\n" +
                "end \r\n" +
                "local num= redis.call(\"get\" ,stockKey);\r\n" +
                "if tonumber(num)<=0 then   return 0;\r\n" +
                "else \r\n " +
                " redis.call(\"decr\",stockKey);\r\n" +
                "redis.call(\"sadd\",userIdKey,userId);\r\n" +
                "end \r\n" +
                "return 1;";

        // 指定 lua 腳本,並且指定返回值類型
        // (為什么返回值不用 Integer 接收而是用 Long。這里是因為 spring-boot-starter-data-redis 提供的返回類型里面不支持 Integer。)
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
        List<String> keys = new ArrayList<>();
        keys.add(usrId);
        keys.add(atcId + ":stock");
        keys.add(atcId + ":userId");
        // 參數一:redisScript,參數二:key列表,參數三:arg(可多個)
        Long result = (Long) redisTemplate.execute(redisScript, keys);
        if (0 == result) {
            System.out.println("秒殺結束了。。。");
        } else if (1 == result) {
            System.out.println("恭喜你!秒殺成功了!");
            return true;
        } else if (2 == result) {
            System.out.println("你已經秒殺成功了,不能重復秒殺");
        } else {
            System.out.println("秒殺異常啦~");
        }

        return false;
    }

lua腳本:

local userId=KEYS[1];
local stockKey=KEYS[2];
local userIdKey=KEYS[3];
local userExists=redis.call("sismember",userIdKey,userId); 
if tonumber(userExists)==1
then
  return 2;
end
local num= redis.call("get" ,stockKey);
if tonumber(num)<=0 then   return 0;
else
 redis.call("decr",stockKey);
redis.call("sadd",userIdKey,userId);
end
return 1;

這樣一個完美的秒殺案例就完成了。嘻嘻嘻~~~~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM