基於Redis的搶票系統


一、概述

以下內容是基於文章秒殺系統設計-敖丙加上自己思考所寫的內容,主要分析了為什么使用Redis以及如何使用Redis實現一個搶票系統。

二、功能分析

2-1、讀取余票數量以及控制開啟時間功能

作為一個搶票系統,應該能夠讀取剩余票量,並且在售票時,進行檢測當前票量是否大於0,如果大於0才能進行搶票,否則拒絕搶票,同時還應該控制搶票開始時間,在搶票未開始時,無法進行搶票。

如果使用MySQL數據庫實現這兩個功能,可以有三種實現。

第一種:將表的字段設計成------票的id、余票數量、搶票開始時間,每次對搶票API進行訪問時,首先檢查搶票是否開啟(系統時間與搶票開始時間進行比較),然后再進行余票檢測(大於0才能搶票),最后進行搶票。這種方法可以提前將數據插入MySQL數據庫,然后能夠很容易的將數據放到緩存中(只要搶票之前進行一次查詢操作就行將結構放到緩存中),開啟搶票后不是訪問數據庫而是訪問緩存,能夠提供很好的效率,但是存在着一個很顯著的問題---每一次對搶票API進行訪問時,都需要進行系統時間的獲取以及比較,搶票開啟前還好,一旦搶票開啟了,這將會成為很大的負擔

第二種:將表的字段設計成------票的id、余票數量,同時只有到了搶票時間再進行數據插入操作,在搶票開啟之前,數據庫查不到數據,返回null,搶票開始后,能夠查到數據,開始搶票。使用這種方法能夠避免頻繁地調用系統時間,但是這種方法存在一個問題,如果並發量很大,我剛將數據存放到數據庫,還沒有到緩存,就發生了很多的查詢,很有可能會導致數據庫掛掉。

第三種:將表的字段依舊設計成------票的id、余票數量,但是與第一種方案類似,提前將數據進行插入到數據庫中,最后通過一個訪問開關來確定這個API能否提供訪問。在搶票開始前,訪問開關返回False,搶票開始的時候,訪問開關返回True,同時在搶票開始前,將數據加載到緩存中。

以上三種方法,對於能夠保證查詢的效率,但是減庫存的操作會非常的慢,甚至可能導致MySQL數據庫掛掉。

簡而言之就是,如果使用MySQL數據庫+緩存的方式提供服務,在一定條件下能夠保證查詢效率,但是一旦涉及到減庫存(修改)操作,速度還是很慢的。

因此對於一個高並發的搶票系統來說,最好的選擇可能就是將這些頻繁修改以及訪問的數據放入到Redis(這類基於內存)服務器中,提供訪問以及修改(主要是為了修改操作),於此同時在搶票結束后,再異步地將數據更新到MySQL數據庫中。

這里主要采用第二種方法:在開啟搶票前,Redis中沒有數據,當開啟搶票時,通過一個定時任務(或者手動執行set命令),將余票數量進行插入,其存在Redis的鍵為---object-type: id:field---ticket:ticketId:stock---票:票的id:票量,而值就是余票,其實就是在鍵中保存了票的id,而值中保存了余票數量。

這里再講一下預約掛號系統的實現,作為一個預約掛號系統其實沒必要直接將數據存放在Redis中(預約掛號系統的並發量不會很高,使用MySQL數據庫加上緩存足以)。如果還想要使用Redis來實現的話,我會將鍵設置為appointment: id: time---預約:診室的id:預約時間(這個預約時間也可以使用時間戳),票量要么為0要么為1。

2-2、扣減庫存、預防超賣

在獲取庫存后,如果庫存大於0則簡單地進行減一操作顯然是只能滿足一般的情況,如果不添加事務或者LUA腳本,在高並發的情況下很容易發生超賣(比如兩個請求轉過來,然后Redis先執行兩個請求的查詢數據操作再進行兩個請求的減庫存操作,注意這個執行順序是按照提交順序來的,多個客戶端同時提交請求很有可能會交叉地執行多個請求中的命令,這種交叉執行命令就會導致數據的不准確,以至於超賣)。

因此我們需要添加事務或者LUA腳本,而這里選擇使用LUA腳本,主要原因是Redis事務是基於樂觀鎖的,而樂觀鎖的核心就是寫操作的沖突不會很多,顯然對於搶票系統這種高並發的情況是非常不適合使用樂觀鎖的,因此使用LUA腳本。(實際上Redis的LUA腳本都快完全替代Redis事務了,Redis事務在高並發的情況下會產生大量的失敗操作)。

2-3、隱藏搶票鏈接

如果用戶能夠提前知道搶票的連接,將會有很大一部分人能夠整點進行秒殺,比如黃牛可能會通過搶票的程序,准時進行搶票,這樣子對普通用戶太不公平了,因此希望搶票鏈接被隱藏起來,同時希望連開發者都不知道搶票鏈接。

2-4、支持高並發、高可用

如果想要支持更高的並發,可以將系統設計成Redis集群(能夠接受更多的請求,分發給redis集群),而如果想要高可用,可以讓系統進行主從復制(防止某一台redis服務器掛了之后,這台服務器無法提供服務)、開啟哨兵(服務器掛了之后,自動將從服務器升級為主服務器,繼續提供服務)、開啟持久化(防止redis服務器掛了之后導致數據的丟失)。

三、代碼編寫

3-1、JedisPool連接工具

// 單例模式(雙重檢驗加鎖)的方式來獲取Jedis連接池,如果不適用連接池的話,只使用一個jedis連接,那這一個jedis出現問題就可能會導致服務無法提供,同時並發量會小
public class JedisPoolUtil {
    private static volatile JedisPool jedisPool;

    private JedisPoolUtil(){}

    public static JedisPool getJedisPoolInstance() {
        if (jedisPool == null){
            synchronized (JedisPoolUtil.class){
                if (jedisPool == null){
                    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
                    // 空閑jedis數
                    jedisPoolConfig.setMaxIdle(8);
                    // 最大連接數
                    jedisPoolConfig.setMaxTotal(16);
                    // 最大等待時長
                    jedisPoolConfig.setMaxWaitMillis(1000);
                    // 獲取連接實例時,是否檢查連接可用
                    jedisPoolConfig.setTestOnBorrow(false);

                    jedisPool = new JedisPool(jedisPoolConfig,"localhost",6379);
                }
            }
        }
        return jedisPool;
    }
}

3-2、搶票控制器,設計上有點問題,控制器不應該用於業務的處理,這里只是一個搶票的小Demo

@Controller
public class GrabTicketController {
    // Lua腳本,防止超賣,如果使用事務,失敗的更多,同時在測試時,容易出現沒賣完(比如短時間內請求了101次,庫存100,由於出現了很多事務失敗導致一部分沒賣掉)
    public static String LuaScript =
            "local ticketNumber = redis.call('GET', KEYS[1])\n" +
            "   if not ticketNumber then\n" +
            "   return -1\n" +
            "elseif tonumber(ticketNumber) <= 0 then\n" +
            "   return 0\n" +
            "else \n" +
            "   redis.call('DECR', KEYS[1])\n" +
            "   redis.call('LPUSH', KEYS[2], ARGV[1])\n" +
            "   return 1\n" +
            "end\n";

    JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
    Jedis jedis;
//    Jedis jedis = new Jedis("localhost",6379);
    
    @PostMapping("/ticket/grabTicket")
    @ResponseBody
    public String grabTicket(int goodsId){
        // 用戶id,應該通過登錄信息獲取,這里沒有做登錄系統,因此以uuid作為搶票的用戶
        String userId = UUID.randomUUID().toString().replaceAll("-", "");
        // redis中某個商品的余票的鍵
        String ticketNumberKey = "ticket:" + goodsId + ":ticketNumber";
        // 搶到票的用戶列表,這里應該是一個生成訂單的服務,但是簡化,就只將用戶列表添加到一個list里面
        String userForGrabTicketKey = "userForGrabTicket:" + goodsId + ":userId";

        try {
            // 獲取redis連接
            jedis = jedisPool.getResource();
            // 獲取返回值,這里有個問題,就是如果Lua腳本執行錯誤,這里將會發生一個類型轉換錯誤(String->Long)
            Long result = (Long) jedis.evalsha(jedis.scriptLoad(LuaScript), 2, ticketNumberKey,userForGrabTicketKey,userId);
            if (result == -1){
                // 搶票未開始
                System.out.println("搶票未開始");
                return "搶票未開始";
            } else if (result == 0) {
                // 票賣完了
                System.out.println("票賣完了");
                return "票賣完了";
            } else {
                // 搶票成功
                System.out.println("搶票成功");
                return "搶票成功";
            }
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            // 釋放連接
            jedis.close();
        }
        return "出現異常,搶票失敗";
    }
}

四、結果展示

4-1、單用戶搶票

4-2、JMeter模擬高並發搶票

沒有超賣,也沒有庫存沒賣掉。


免責聲明!

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



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