秒殺系統項目總結


秒殺系統項目的設計:

  • 項目使用了spring-boot集成了Mybatis,使用Druid配置mysql數據庫的連接信息。
  • 4個優化:
    • 優化1:使用Redis做頁面緩存+對象緩存
    • 優化2:Redis預減庫存 + 內存標記減少Redis訪問 + RabbitMQ隊列緩沖,異步下單
    • 優化3:關於多線程下超賣問題解決
    • 優化4:頁面靜態化
  • 4個封裝
    • 封裝1:Redis通用緩存Key類封裝設計
    • 封裝2:分布式Session中根據Token獲取用戶,並將User封裝注入到方法的參數中
    • 封裝3:全局異常攔截器
    • 封裝4:自定義注解類Access(點擊限制), IsMobile(驗證手機號), NeedLogin(判斷是否登錄)
  • 1個橫向擴展
    • mysql和Redis在同一台高性能服務器上
    • 一台master,一台backup實現主從熱備
    • 4台tomcat服務器輪詢處理請求

4個優化

1 Redis使用

1 封裝1: Redis通用緩存Key封裝設計,做對象緩存,商品詳情頁面緩存,訂單緩存,商品信息緩存

  • 背景:Redis做緩存時,可能會設置很多key,來標識我們需要存取的數據,如何能夠保證key的唯一呢?
  • 設計:key加一個前綴,例如:用戶相關的緩存都以User為前綴,商品所有的緩存都以Goods為前綴......。我們在key前拼接上前綴,作為redis中真正讀寫的key,這樣就能使得key唯一且易區分
  • 實現:
    • 接口--->抽象類--->實現類:接口就是定義一些契約,抽象類來做一些共同的操作,實現類依照特定的要求來完成具體功能,這種設計也是非常常用的。
    • 接口:定義了獲取過期時間和獲取前綴兩個方法
    • 抽象類:編寫了一些共性的邏輯代碼,比如獲取過去時間和獲取前綴的具體實現
    • 實現類:通過構造函數傳遞接受真正的參數,比如過期時間,前綴(key前綴的組成有兩部分:第一部分是類名,第二部分是參數指定的比如id, name);
  • 效果: 簡化了使用過程redis過程中Key名容易出現重復的,實現每次在Redis中存儲Key時都會自動帶上該類的類名className ,比如存儲用戶對象時的UserKey,會將Key設置為className + ":" + prefix (User:id1)

2 優化1:頁面緩存+對象緩存

對象緩存
  • 將用戶ID作為key,Token作為value存入緩存,用戶登陸時可以不用去查數據庫直接查緩存
  • 同理商品列表也可以做對象緩存
  • 需要注意緩存一致性問題,調用更新數據庫的時候注意要處理緩存,這里采用的策略是User緩存先寫數據庫再更新緩存,而Goods緩存是只更緩存,不更MySQL,MySQL由緩存異步下單時做更新。
頁面緩存
  • 設計: 1)取緩存,如果緩存里面有這個頁面,直接輸出到前端 2)如果緩存中沒有,則手動渲染模板,結果輸出,並將頁面寫入到Redis中,有效期為60秒(用戶一分鍾內看這個界面一般不會有改變,因為界面里沒有展示庫存這些信息)。
  • 實現:
    • 在Controller方法中,@RequestMapping注解中添加參數produces="text/html,以及添加@ResponseBody注解
    • 在Controller方法取緩存,並判斷是否為空,如果這個頁面緩存不為空,直接返回
    • 如果為空,則手動渲染,渲染完保存到Redis中
    • 頁面緩存有效期一般比較短60秒
    • 頁面緩存非常適合用在一些沒有業務參數的頁面,比如沒有商品列表沒有庫存
    • 如果有業務參數,那么通過model去傳遞
// 手動渲染
SpringWebContext ctx = new SpringWebContext(request,
                response,
                request.getServletContext(),
                request.getLocale(),
                model.asMap(),
                applicationContext );
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", ctx);

3 集成Jedis+Redis

  • RedisPoolFactory來生成RedisPool
  • 使用Jedis連接池獲取Redis對象
  • java對象序列化沒有使用谷歌的protobuf(最快,序列化是二進制),而是采用Fastjson(序列化之后明文可讀)

2 優化2:Redis預減庫存 + 內存標記減少Redis訪問 + RabbitMQ隊列緩沖,異步下單

1 效果:

  • 獲取商品列表這個業務
    • 原有QPS:2,348
    • Redis后QPS:16155
  • 秒殺商品這個業務:
    • Redis:5,992.33
    • Redis+RabbitMQ QPS: 11,775.789

2 redis預減庫存,rabbitmq異步下單

  • 原來的流程需要做1)判斷用戶登錄狀態 2)判斷庫存 3)判斷是否秒殺到 4)真正的秒殺-減庫/存創建訂單
  • 原有QPS1350
  • 優化同步下單改成異步下單
  • 1 系統初始化時,把商品庫存數量加載到Redis(實現InitializingBean接口重寫afterPropertiesSet)
  • 2 收到請求,Redis預減庫存,庫存不足直接返回
  • 3 請求入隊,立即返回排隊中,入隊的是秒殺信息對象(id+name)的轉化成的字符串
    • 4 請求出隊(異步),生成訂單之后要保存在redis中,方便判斷是否重復下單
    • 5 客戶端輪詢,是否秒殺成功
  • 進一步優化 內存標記
    • 之前每次預減庫存都要查一次redis,雖然Redis很快,但也是有消耗的
    • 於是使用在內存中使用一個localOverMap,key為goodsId,已經賣完了,就不用再去查Redis了

補充:RabbitMQ的4種使用方法

1 Direct模式 交換機Exchange

  • 使用一個隊列QUEUE,點對點模式,一端存一端取
  • 它包含一個生產者、一個消費者和一個隊列。生產者向隊列里發送消息,消費者從隊列中獲取消息並消費。

2 Topic模式 交換機Exchange

  • 多個隊列,支持通配符配置key將消息發送到匹配的隊列中
  • 首先將消息發送到一個exchange中
  • 多個隊列綁定到一個中心節點exchage中,exchange根據通配符將消息發送到不同的隊列
  • 這種模式較為復雜,簡單來說,就是每個隊列都有其關心的主題,所有的消息都帶有一個"標題"(RouteKey),Exchange會將消息轉發到所有關注主題能與RouteKey模糊匹配的隊列。
  • 這種模式需要RouteKey,一般要提前綁定Exchange與Queue。
  • 如果Exchange沒有發現能夠與RouteKey匹配的Queue,則會拋棄此消息。

3 Fanout模式 交換機Exchange

  • fanout模式比較簡單,廣播式的,無視routingkey直接發送給所有的queue

4 Header模式 交換機Exchange

  • 根據設置的頭部信息去發送消息

3 優化3: 關於多線程下超賣問題解決

  • 當僅剩一個商品時,多個線程同時下單,導致庫存減到了0;
    • 解決: 插入數據庫之前sql語句加個and條件 當庫存>0時才會真正去下單
      因為數據庫每次更新都會作為一個事務,當這個線程獲取了商品表時,會對商品表上鎖,不會讓兩個線程同時做更新操作,
  • 防止一個用戶重復下單(只能秒殺一次的情況下)(因為是在多線程情況下,一個用戶可能兩個商品預減庫存都能成功,並且兩個線程都還沒有創建訂單,他們會同時創建訂單,導致超賣)
    • 解決: 在訂單表中創建唯一索引即用戶id-商品id,保證商品不會被重復下單
      其實也還可以通過驗證碼等方式下單(但會影響下單體驗)

4 優化4:頁面靜態化

  • 背景:原來是使用獲取動態獲取頁面信息,即請求一個頁面獲取一個頁面的html
  • 設計:將頁面轉成純HTML靜態頁面,通過js和ajax獲取和渲染動態數據,實現客戶端緩存html頁面,只需要向服務端請求動態數據
  • 實現:
    • 在html文件中編寫大量的js代碼獲取動態數據!
    • 比如botton 的onclick()
function doMiaosha(path){
    $.ajax({
        url:"/miaosha/"+path+"/do_miaosha",
        type:"POST",
        data:{
            goodsId:$("#goodsId").val()
        },
        success:function(data){
            if(data.code == 0){
                //window.location.href="/order_detail.htm?orderId="+data.data.id;
                getMiaoshaResult($("#goodsId").val());
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客戶端請求有誤");
        }
    });
}

資源靜態化補充: 靜態資源優化+CDN優化

  • JS/CSS壓縮,減少流量
  • 多個JS/CSS組合,減少連接數
  • CDN服務器就近訪問

5 分布式Session封裝

1 封裝2:根據Token獲取用戶封裝注入到方法的參數中

  • 背景:除了登錄功能需要用戶信息,還有很多頁面其實也是需要用戶信息,比如商品詳情頁
  • 以前:以前是在每個Controller方法里通過request,response對象一行一行代碼解析Cookie獲取Tooken,然后獲取User對象。
  • 設計: 使用攔截器通過Token獲取User對象並自動的注入到方法的參數中。
  • 具體做法是
    • 編寫了WebConfig,繼承WebMvcConfigurerAdapter,重寫addArgumentResolvers實現自定義參數處理器,將userArgumentResolver類添加到參數處理器中。
    • addArgumentResolvers是SpringBoot框架回調Controller方法時,將Controller參數里面賦值
    • 編寫解析自定義類UserArgumentResolver,UserArgumentResolver實現HandlerMethodArgumentResolver類,重寫supportsParameter和resolveArgument

2 什么是Session

  • 1 服務端在用戶登錄成功之后生成一個類似於SessionID的東西來保存用戶,比如Token,來標識這個用戶,寫入到Cookie中,傳給客戶端
  • 2 客戶端在隨后的訪問請求中都在cookie中上傳這個token
  • 3 服務端拿到這個Token后,根據Token獲取Token對應的用戶的信息

3 Session的作用

Session 的主要作⽤就是通過服務端記錄⽤戶的狀態。 典型的場景是購物⻋,當你要添加商品到 購物⻋的時候,系統不知道是哪個⽤戶操作的,因為 HTTP 協議是⽆狀態的。服務端給特定的⽤ 戶創建特定的 Session 之后就可以標識這個⽤戶並且跟蹤這個⽤戶了。 Cookie 數據保存在客戶端(瀏覽器端),Session 數據保存在服務器端。相對來說 Session 安全 性更⾼。如果使⽤ Cookie 的⼀些敏感信息不要寫⼊ Cookie 中,最好能將 Cookie 信息加密然 后使⽤到的時候再去服務器端解密。

4 我們是怎樣使用Session的

  • 有一種解決辦法是Session同步,將第一台機器的Session同步到第二天機器,但是機器數量多了這個同步的過程就會非常恐怖
  • 因此一般會使用分布式Session,我們將用戶信息存放在第三方的Redis服務器中,使用Token作為Redis的key去獲取用戶

4 封裝3:全局異常攔截器

  • 背景:想在前端中看到異常信息,就要在業務代碼中逐個逐個處理返回這個異常信息。(比如登錄失敗也就是出現異常,前端頁面並不知道發生了什么錯誤)
  • 設計: 設計全局異常類,當遇到異常的時候直接拋出異常即可,然后返回true或fasle,不再是返回一個異常或者true。實現將異常處理和業務代碼隔離開
  • 實現:
    • 創建全局異常類GlobalException,繼承自RuntimeException
    • 使用原有的@ExceptionHandler注解攔截異常類,在將全局異常處理類GlobalExceptionHandler使用@ControllerAdvice交給容器去管理
    • 在全局異常類處理器中,當遇到的異常是GlobalException,那么返回異常Result信息

5 封裝4:自定義注解Access, IsMobile, NeedLogin

1 使用自定義注解的方式實現點擊限流攔截器,Access, NeedLogin

  • 背景:點擊限流功能代碼寫在Controller代碼中,看起來很復雜
  • 設計:使用自定義注解的方式實現點擊限流攔截器,與業務代碼隔離開來
  • 實現:
    • 1 新建一個@interface注解類,類中定義了使用這個注解時的參數,其實相當於一個Bean對象
    • 2 編寫對應的Handler類AccessInteceptor,需要繼承自HandlerInterceptorAdapter,重寫preHandle方法,這個方法中包含了request,response還有handler對象
    • 3 可以從preHandle方法的handler參數中拿到SpringBoot掃描出來的注解,還有注解的參數
    • 4 response中拿到user用戶,將user用戶放在ThreadLocal 中,ThreadLocal是與當前線程有關的,而每次秒殺的一個過程應該都是在一個線程內進行
    • 5 注冊攔截器,在WebConfig類中注冊AccessInteceptor

2 Jsr303參數驗證器IsMobile , NeedLogin 注解

  • 框架幫我們定義好了NotNull, Length, Pattern校驗器
  • 我們自己重新實現了一個IsMobile的驗證器


免責聲明!

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



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