電商秒殺系統相關實現

前言

本文主要就電商秒殺系統所涉及的相關技術進行探究,相關Demo地址如下:

本Demo實現了電商項目的秒殺功能,主要內容包含了用戶登錄、瀏覽商品、秒殺搶購、創建訂單等功能,着重解決秒殺系統的併發問題。項目利用JMeter工具進行壓力測試,着重對比採用緩存、消息隊列等手段對於提高系統響應速度併發能力的效果。

項目視頻參考地址:https://www.bilibili.com/video/av50818180/

一、快速開始

「環境準備」

  1. 安裝Docker;
  2. 安裝MySQL的Docker環境 (注意設置容器時區和MySQL時區);
    docker pull mysql:5.7.23
    docker run --name e3-mall-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=1234 -e TZ=Asia/Shanghai -d mysql:5.7.23 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-time_zone='+8:00'
    
  3. 安裝Redis的Docker環境;
    docker pull redis:3.2
    docker run -d -p 6379:6379 --name e3-mall-redis -e TZ=Asia/Shanghai redis:3.2
    
  4. 安裝RabbitMQ的Docker環境;
    docker pull rabbitmq:3.7-management
    docker run -d --name e3-mall-rabbitmq -p 5672:5672 -p 15672:15672 --hostname e3-mall-rabbitmq -e RABBITMQ_DEFAULT_VHOST=mq_vhost  -e RABBITMQ_DEFAULT_USER=admin -e RABBITMQ_DEFAULT_PASS=admin -e TZ=Asia/Shanghai rabbitmq:3.7-management
    

「導入項目」

  1. 克隆本項目 https://github.com/MrSorrow/seckill
  2. 項目採用Maven構建;
  3. 通過IDEA直接以Maven方式導入項目即可。

二、功能實現

I. 用戶模塊

「建立用戶表」

CREATE TABLE miaosha_user (
    id BIGINT(20) NOT NULL PRIMARY KEY COMMENT "用戶ID,手機號碼",
    nickname VARCHAR(255) NOT NULL COMMENT "暱稱",
    password VARCHAR(32) DEFAULT NULL COMMENT "MD5(MD5(pass明文, 固定SALT), 隨機SALT)",
    salt VARCHAR(10) DEFAULT NULL,
    head VARCHAR(128) DEFAULT NULL COMMENT "頭像,雲存儲ID",
    register_date datetime DEFAULT NULL COMMENT "註冊時間",
    last_login_date datetime DEFAULT NULL COMMENT "上一次登錄時間",
    login_count int(11) DEFAULT '0' COMMENT "登錄次數"
) ENGINE INNODB DEFAULT CHARSET=utf8mb4;

「兩次MD5密碼加密」

  1. 客戶端登錄時避免明文密碼在網絡中傳輸,所以進行客戶端第一次MD5;
  2. MD5的密碼傳輸至服務端時,需要隨機生成salt進行二次MD5,保存salt和兩次MD5結果至數據庫中。

「分佈式Session」

  1. UUID方式生成Token,Redis保存Token-User的鍵值信息模擬Session;
  2. 將Token寫到Cookie中,設置Path爲頂級域名之下。

「註冊登錄功能實現」

  1. 封裝服務端響應對象 guo.ping.seckill.result.ServerResponse 以及狀態碼消息對象 guo.ping.seckill.result.CodeMsg;
  2. 利用JSR-303註解校驗參數,並自定義手機號校驗註解 guo.ping.seckill.validator.IsMobile 以及驗證器 guo.ping.seckill.validator.IsMobileValidator;
  3. 實現用戶登錄,批量註冊用戶邏輯;
  4. 自定義方法參數解析器用於獲取請求中包含的Token值,並查詢Redis封裝成User;
  5. 具體代碼參考 Commits on May 3, 2019Commits on May 5, 2019

II. 商品模塊

「建立商品與訂單表」

  1. 數據庫表包含普通商品表、普通訂單表,以及專門用於秒殺商品的秒殺商品表和秒殺訂單表;
  2. 普通商品表:
    CREATE TABLE goods (
        id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT "商品id",
        goods_name VARCHAR(16) DEFAULT NULL COMMENT "商品名稱",
        goods_title VARCHAR(64) DEFAULT NULL COMMENT "商品標題",
        goods_img VARCHAR(64) DEFAULT NULL COMMENT "商品圖片",
        goods_detail LONGTEXT COMMENT "商品詳情",
        goods_price DECIMAL(10, 2) DEFAULT '0.00' COMMENT "商品單價",
        goods_stock INT(11) DEFAULT 0 COMMENT "-1表示沒有限制"	
    ) ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
    
  3. 秒殺商品表:
    CREATE TABLE miaosha_goods (
        id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT "秒殺商品id",
        goods_id BIGINT(20) DEFAULT NULL COMMENT "商品id",
        miaosha_price DECIMAL(10, 2) DEFAULT '0.00' COMMENT "商品秒殺單價",
        stock_count INT(11) DEFAULT NULL COMMENT "庫存數量",
        start_date DATETIME DEFAULT NULL COMMENT "秒殺開始時間",
        end_date DATETIME DEFAULT NULL COMMENT "秒殺結束時間"
    ) ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
    
  4. 普通訂單表:
    CREATE TABLE order_info (
        id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT "訂單id",
        user_id BIGINT(20) DEFAULT NULL COMMENT "用戶id",
        goods_id BIGINT(20) DEFAULT NULL COMMENT "商品id",
        goods_name VARCHAR(16) DEFAULT NULL COMMENT "反範式冗餘的商品名稱",
        goods_count INT(11) DEFAULT '0' COMMENT "商品數量",
        goods_price DECIMAL(10, 2) DEFAULT '0.00' COMMENT "商品單價",
        delivery_addr_id BIGINT(20) DEFAULT NULL COMMENT "收貨地址id",
        order_channel TINYINT(4) DEFAULT '0' COMMENT "1-pc,2-android,3-ios",
        status TINYINT(4) DEFAULT '0' COMMENT "訂單狀態 0-新建未支付,1-已支付,2-已發貨,3-已收貨,4-已退款,5-已完成",
        create_date DATETIME DEFAULT NULL COMMENT "創建訂單時間",
        pay_date DATETIME DEFAULT NULL COMMENT "支付時間"
    ) ENGINE=INNODB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4;
    
  5. 秒殺訂單表:
    CREATE TABLE miaosha_order (
        id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT "秒殺訂單id",
        user_id BIGINT(20) DEFAULT NULL COMMENT "用戶id",
        order_id BIGINT(20) DEFAULT NULL COMMENT "訂單id",
        goods_id BIGINT(20) DEFAULT NULL COMMENT "商品id"
    ) ENGINE=INNODB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4;
    

「展示商品列表及詳情」

  1. 插入商品數據:
    INSERT INTO goods VALUES (1, "iPhoneX", "Apple iPhone X (A1865) 64GB 深空灰色 移動聯通電信4G手機", "/img/iphonex.png", "Apple iPhone X (A1865) 64GB 深空灰色 移動聯通電信4G手機【五月特惠】大屏性價比iPhone7Plus4199元,iPhone8低至3499元,iPhoneXR低至4799元!更多特價、優惠券,點此查看!選移動,享大流量,不換號購機!", 5999, 100);
    INSERT INTO goods VALUES (2, "華爲 P30", "華爲 HUAWEI P30 Pro 超感光徠卡四攝10倍混合變焦麒麟980芯片屏內指紋", "/img/p30.png", "華爲 HUAWEI P30 Pro 超感光徠卡四攝10倍混合變焦麒麟980芯片屏內指紋 8GB+256GB極光色全網通版雙4G手機!", 5988, 55);   
    
    INSERT INTO miaosha_goods VALUES (1, 1, 0.01, 4, NOW(), NOW()), (2, 2, 0.01, 9, NOW(), NOW());
    
  2. 通過連接普通商品表和秒殺商品表,查詢出秒殺商品的全部信息;
  3. 將所有商品展示在 goods_list 中,單個商品的全部信息展示在商品詳情頁 goods_detail 中。

「下單秒殺商品」

  1. 下單秒殺商品包含減少庫存、創建訂單兩步,需要保證兩步操作的原子性;
  2. 通過Spring聲明式事務,保證減少庫存、創建普通訂單以及創建秒殺訂單三步的原子性;
  3. 秒殺倒計時刷新由前端完成,後端僅僅在查看商品詳情時返回一次計算的剩餘時間即可,保證所有客戶端的秒殺時間能夠同步,以後端爲準;
  4. 具體代碼參考 Commits on May 6, 2019

三、 壓測優化

I. 壓力測試

利用JMeter對接口進行壓測。壓測文件保存在 preesure_test 文件夾下。

測試時MySQL、Redis連接池配置參數如下:

# druid pool config
druid:
  initial-size: 100
  max-active: 1000
  min-idle: 500
  max-wait: 5000
# redis pool config
redis:
  timeout: 5000
  lettuce:
    pool:
      max-active: 2000
      max-idle: 200
      max-wait: 3

由於設置的數據庫連接池連接數量較大,需要對Docker-MySQL的最大連接數進行設置:

set GLOBAL max_connections=2000;
set GLOBAL max_user_connections=1500;

未採取任何優化措施前,部分核心接口測試性能如下:

壓測接口 接口地址 線程數*循環次數 異常 % 吞吐量
商品列表接口壓測 http://localhost:8080/goods/list 5000*1 0.000% 467.1 / sec
單用戶信息查詢壓測 http://localhost:8080/user/info 5000*1 0.000% 996.9 / sec
多用戶秒殺接口壓測 http://localhost:8080/seckill/kill 5000*1 0.000% 289.8 / sec

II. 性能優化

系統性能瓶頸在於MySQL的讀寫性能,所以主要利用各種技術減少對數據庫層面的訪問。

從緩存實現性能優化角度,用戶的請求首先經過CDN優化,然後經過Nginx服務器緩存,再進入應用程序實現的頁面級緩存,然後經過對象級緩存,最後可能經過數據庫緩存。前面的層層緩存使得用戶請求讀寫數據庫的壓力減小很多。
頁面緩存優化技術包括:

  1. 頁面緩存+URL緩存+對象緩存
  2. 頁面靜態化,前後端分離
  3. 靜態資源優化
  4. CDN優化

以上是否通用接口的優化方式,針對秒殺場景,較短時間內大量的秒殺請求訪問系統,根據其讀多寫少的業務特性,一旦商品秒殺結束,後續的秒殺請求一律返回秒殺失敗即可,無需再訪問緩存或數據庫。
秒殺接口優化策略包括:

  1. Redis預減庫存減少對數據庫的訪問,只有庫存數量的請求才會真正訪問數據庫進行下單;
  2. 通過內存標記減少Redis訪問。如果Redis預減庫存已經減完,則之後的請求無需繼續訪問Redis繼續預減;
  3. Redis放行的請求先進入消息隊列進行緩衝,異步請求數據庫下單,增強用戶體驗

其他提高系統併發能力的策略主要就是服務的水平復制與垂直拆分,構建分佈式集羣應用,通過多個節點分擔壓力,負載均衡等。

「模板引擎頁面緩存」

  1. 頁面緩存主要是指 Controller 方法返回的不再是包含數據的 Json,而是直接返回最終的HTML頁面;
  2. 通過頁面模板引擎將 Service 層得到的數據直接手動渲染頁面,將整個頁面緩存至Redis中;
  3. 緩存的過期時間可以設定較短,保證用戶看到的數據不至於過舊。

「模板引擎URL緩存」

  1. URL緩存主要是指針對不同的請求URL,緩存對應的HTML頁面,基本和頁面緩存相似;
  2. 通過以不同的URL爲緩存的鍵來存儲不同的頁面;
  3. 同樣緩存的淘汰策略也是自動過期。

「對象緩存」

  1. 對象緩存主要是指將 Service 層得到的數據存入Redis中,這樣每次請求先進行緩存查詢,如果命中則直接返回;
  2. 對象緩存一般沒有變化則常駐於緩存,或設置較久的過期時間。數據在更新時,需要對緩存進行處理,往往先更新數據庫,再刪除緩存。

「頁面靜態化」

  1. 主要針對商品詳情頁,可以採取頁面靜態化的方式,直接生成靜態HTML頁面,保存在本地,額外用高性能HTTP服務器進行訪問;
  2. 頁面靜態化方式可以使得前後端分離,一種方式是後端只提供RESTful API接口,前端編寫半靜態頁面,通過Ajax獲取數據再進行頁面填充;
  3. 另一種頁面靜態化方式可以直接在添加商品時,利用頁面模板引擎生成完全靜態的商品詳情網頁,如果數據更改再重新生成新的頁面。

「瀏覽器緩存」

通過瀏覽器端的緩存,可以降低對服務器的訪問次數。Spring Boot對於靜態資源的瀏覽器緩存相關配置如下:

# SPRING RESOURCES HANDLING (ResourceProperties)
spring.resources.add-mappings=true # Whether to enable default resource handling.
spring.resources.cache.cachecontrol.cache-private= # Indicate that the response message is intended for a single user and must not be stored by a shared cache.
spring.resources.cache.cachecontrol.cache-public= # Indicate that any cache may store the response.
spring.resources.cache.cachecontrol.max-age= # Maximum time the response should be cached, in seconds if no duration suffix is not specified.
spring.resources.cache.cachecontrol.must-revalidate= # Indicate that once it has become stale, a cache must not use the response without re-validating it with the server.
spring.resources.cache.cachecontrol.no-cache= # Indicate that the cached response can be reused only if re-validated with the server.
spring.resources.cache.cachecontrol.no-store= # Indicate to not cache the response in any case.
spring.resources.cache.cachecontrol.no-transform= # Indicate intermediaries (caches and others) that they should not transform the response content.
spring.resources.cache.cachecontrol.proxy-revalidate= # Same meaning as the "must-revalidate" directive, except that it does not apply to private caches.
spring.resources.cache.cachecontrol.s-max-age= # Maximum time the response should be cached by shared caches, in seconds if no duration suffix is not specified.
spring.resources.cache.cachecontrol.stale-if-error= # Maximum time the response may be used when errors are encountered, in seconds if no duration suffix is not specified.
spring.resources.cache.cachecontrol.stale-while-revalidate= # Maximum time the response can be served after it becomes stale, in seconds if no duration suffix is not specified.
spring.resources.cache.period= # Cache period for the resources served by the resource handler. If a duration suffix is not specified, seconds will be used.
spring.resources.chain.cache=true # Whether to enable caching in the Resource chain.
spring.resources.chain.compressed=false # Whether to enable resolution of already compressed resources (gzip, brotli).
spring.resources.chain.enabled= # Whether to enable the Spring Resource Handling chain. By default, disabled unless at least one strategy has been enabled.
spring.resources.chain.html-application-cache=false # Whether to enable HTML5 application cache manifest rewriting.
spring.resources.chain.strategy.content.enabled=false # Whether to enable the content Version Strategy.
spring.resources.chain.strategy.content.paths=/** # Comma-separated list of patterns to apply to the content Version Strategy.
spring.resources.chain.strategy.fixed.enabled=false # Whether to enable the fixed Version Strategy.
spring.resources.chain.strategy.fixed.paths=/** # Comma-separated list of patterns to apply to the fixed Version Strategy.
spring.resources.chain.strategy.fixed.version= # Version string to use for the fixed Version Strategy.
spring.resources.static-locations=classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/ # 靜態資源路徑

「靜態資源優化」

  1. 對JS/CSS文件壓縮,減少流量;
  2. 多個JS/CSS進行組合爲一個文件,減少請求資源時建立的TCP連接數;
  3. 利用WebPack可以進行前端資源打包。

「CDN就近訪問優化」

  1. CDN:內容分發網絡(Content Delivery Network),CDN系統能夠實時的根據網絡流量和各節點的連接、負載情況選擇從最近的節點將數據返回給用戶;
  2. 購買現有的如阿里雲CDN,騰訊CDN,百度CDN等等服務。

「消息隊列削峯」

  1. 系統初始化,把商品庫存數量加載到Redis中;
  2. 當用戶進行秒殺請求時,Redis預減庫存,如果庫存不足,可以直接返回商品秒殺完成;
  3. 如果還有庫存,則用戶所有的秒殺請求全部進入消息隊列,頁面先顯示排隊中;
  4. 秒殺請求出隊,減少數據庫中的庫存並生成秒殺訂單;
  5. 客戶端則需要通過js不斷輪詢是否秒殺成功,輪詢方式爲查詢Redis中是否有訂單記錄以及Redis中設置的秒殺活動是否結束標記。

III. 安全優化

針對秒殺活動的安全問題,需要防止用戶使用工具刷秒殺地址,所以需要隱藏秒殺接口地址,秒殺地址依靠請求服務端返回。加入驗證碼機制也可以防止機器人惡意刷新,同時限制瞬時請求數量。後端相關服務進行限流,限制用戶一段時間內的最高訪問次數。

「隱藏秒殺接口」

  1. 秒殺搶購時,先請求服務端生成隨機的秒殺地址,服務端緩存隨機秒殺地址用於校驗;
  2. 然後再通過請求拿到的隨機秒殺地址,服務端先進行校驗(和緩存對比),再決定是否進行秒殺商品。

「接口限流防刷」

  1. 利用Redis記錄用戶對於某個請求的一段時間內的訪問次數;
  2. 如果超時,Redis對於某個請求的訪問次數緩存會失效(重新計時);
  3. 自定義限流注解 @AccessLimit,利用Spring提供的攔截器對每一個請求方法進行判斷,是否包含限流注解。

四、打包部署

I. 項目打包

  1. 將Spring Boot項目打包成Jar包,指定打包路徑和打包名稱;
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
        <finalName>seckill-springboot</finalName>
        <directory>/Users/guoping/Projects/Intelljidea/seckill/seckill/</directory>
    </build>
    
  2. 編寫Dockerfile構建鏡像,參考官方文檔;
    # 基礎鏡像
    FROM java:8
    # 掛載點爲/tmp,jar包就會存在這裏
    VOLUME /tmp
    # 拷貝打包好的jar包
    COPY seckill-springboot.jar seckill-springboot.jar
    # 暴露端口
    EXPOSE 8080
    # 容器創建後執行jar
    ENTRYPOINT ["java","-jar","/seckill-springboot.jar"]
    
  3. 構建鏡像創建實例並運行服務;
    docker build -t guoping/seckill:1.0 .
    docker run -d -p 80:8080 --name e3-mall-seckill guoping/seckill:1.0
    
  4. 測試服務。

II. 水平復制

  1. 啓動3個實例,分別端口映射到宿主機的8081、8082、8083,打開宿主機防火牆;
    docker run -d -p 8081:8080 --name e3-mall-seckill-1 -e TZ=Asia/Shanghai guoping/seckill:1.0
    docker run -d -p 8082:8080 --name e3-mall-seckill-2 -e TZ=Asia/Shanghai guoping/seckill:1.0
    docker run -d -p 8083:8080 --name e3-mall-seckill-3 -e TZ=Asia/Shanghai guoping/seckill:1.0
    
  2. 利用Nginx進行反向代理,宿主機上傳好 nginx.conf 配置文件,其中配置好多節點負載均衡;
    events {
        worker_connections  6000;
    }
    
    .......
    
    upstream e3-mall-seckill-servers {
        server 192.168.2.113:8081 weight=1;
        server 192.168.2.113:8082 weight=1;
        server 192.168.2.113:8083 weight=1;
    }
    
    server {
        listen       80;
        server_name  localhost;
    
        location / {
            proxy_pass   http://e3-mall-seckill-servers;
            index  index.html index.htm;
        }
    }
    
  3. 下載Nginx鏡像,創建實例反向代理Nginx服務器。
    docker pull nginx
    docker run --name e3-mall-nginx -v /root/nginx.conf:/etc/nginx/nginx.conf:ro -p 80:80 -d -e TZ=Asia/Shanghai nginx
    
[root@localhost ~]# ls
anaconda-ks.cfg  Dockerfile  seckill-springboot.jar
[root@localhost ~]# docker build -t guoping/seckill:1.0 .
Sending build context to Docker daemon  38.35MB
Step 1/5 : FROM java:8
8: Pulling from library/java
5040bd298390: Pull complete 
fce5728aad85: Pull complete 
76610ec20bf5: Pull complete 
60170fec2151: Pull complete 
e98f73de8f0d: Pull complete 
11f7af24ed9c: Pull complete 
49e2d6393f32: Pull complete 
bb9cdec9c7f3: Pull complete 
Digest: sha256:c1ff613e8ba25833d2e1940da0940c3824f03f802c449f3d1815a66b7f8c0e9d
Status: Downloaded newer image for java:8
 ---> d23bdf5b1b1b
Step 2/5 : VOLUME /tmp
 ---> Running in c846eebffe0c
Removing intermediate container c846eebffe0c
 ---> 11169eefdf99
Step 3/5 : COPY seckill-springboot.jar seckill-springboot.jar
 ---> b4c86a1b8ade
Step 4/5 : EXPOSE 8080
 ---> Running in 3215803d2ea8
Removing intermediate container 3215803d2ea8
 ---> ceaab0ec1c5d
Step 5/5 : ENTRYPOINT ["java","-jar","/seckill-springboot.jar"]
 ---> Running in a27bb88d19d4
Removing intermediate container a27bb88d19d4
 ---> b184325f919e
Successfully built b184325f919e
Successfully tagged guoping/seckill:1.0
[root@localhost ~]# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
guoping/seckill     1.0                 b184325f919e        9 seconds ago       682MB
mysql               5.7.23              1b30b36ae96a        6 months ago        372MB
redis               3.2                 87856cc39862        6 months ago        76MB
java                8                   d23bdf5b1b1b        2 years ago         643MB
[root@localhost ~]# docker run -d -p 80:8080 --name e3-mall-seckill guoping/seckill:1.0
8ff680628f8434f4c8318061188c722d87655293f75957551f6bd87e159c4abc
[root@localhost ~]# docker ps
CONTAINER ID        IMAGE                 COMMAND                  CREATED             STATUS              PORTS                               NAMES
8ff680628f84        guoping/seckill:1.0   "java -jar /seckill-…"   4 minutes ago       Up 4 minutes        0.0.0.0:80->8080/tcp                e3-mall-seckill
dd194cf5bad6        mysql:5.7.23          "docker-entrypoint.s…"   23 hours ago        Up 23 hours         0.0.0.0:3306->3306/tcp, 33060/tcp   e3-mall-mysql
1a244491f479        redis:3.2             "docker-entrypoint.s…"   8 days ago          Up 8 days           0.0.0.0:6379->6379/tcp              e3-mall-redis

五、實現細節

I. 數據庫連接問題

當對接口進行壓測時,瞬時啓動5000個線程去請求接口。我們查看MySQL容器日誌時發現,無法創建數據庫連接,如下所示。
壓測下限制數據庫最大連接數
這是由於我們在 application.yaml 中配置了1000個數據庫連接請求,默認的MySQL容器最大連接數根本沒有這麼多,只有151。
查詢Docker容器的最大連接數
可以通過下面SQL語句查詢數據庫最大連接數。更改最大連接數目用下面兩個SQL語句即可,但缺點是MySQL重啓即會失效。

show global variables like "max%connections";
set GLOBAL max_connections=2000;
set GLOBAL max_user_connections=1500;

設置結果如下所示:
設置最大連接數
設置完成後,MySQL容器便不會報錯,同時可以發現系統啓動時Druid連接池初始化數據庫連接時長也加長了很多。

下圖是測試商品列表接口時,宿主機的性能反映,可以看出消耗最高的兩個分別是我們的應用Java進程和查詢MySQL進程。
測試商品列表虛擬機性能

II. JMeter報錯問題

這一部分並沒有解決,原本想照着視頻上啓動5000個線程循環10次,但發現,JMeter壓測到半途總是報錯,請求異常,錯誤都是網絡連接的問題。以下是嘗試解決方案,並沒有解決問題。

修改操作系統註冊表

1、打開註冊表:regedit
2、找到HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\TCPIP\Parameters
3、新建 DWORD值,name:TcpTimedWaitDelay,value:30(十進制) ——> 設置爲30秒(默認240)
4、新建 DWORD值,name:MaxUserPort,value:65534(十進制) ——> 設置最大連接數65534
5、重啓系統就行了

修改操作系統註冊表

III. 商品超賣問題

商品超賣問題是秒殺系統中除了性能優化,另一個最爲關注的問題之一。由於我們的代碼在生產環境中面臨着超高併發的挑戰,考慮業務需求我們也幾乎不會對控制層代碼進行加鎖保證線程安全。所以,不做其他操作的情況下,極有可能發生商品超賣的問題。

秒殺商品和正常下單的邏輯是一致的,一旦確定可以下單了,業務邏輯主要包含兩件事:

  1. 扣減庫存 (前提庫存程序判斷肯定是夠的,注意是程序,不代表真的夠);
  2. 創建秒殺訂單,或者正常訂單,反正是要落地的。

上面兩步必須要麼全部成功,要麼全部失敗。很容易想到,我們可以通過事務機制來保證這一原子特性。

「秒殺接口邏輯」

我們實現對應的控制層秒殺接口,可以看到,我們的業務規則首先是判斷用戶是否登錄,然後查看商品的庫存是否充足,充足的情況下再判斷該用戶是否已經成功秒殺過該商品 (每人限秒一個)。上述過程都滿足,我們纔會去進行秒殺商品,也就是包括減庫存和下單兩件事。

//SecKillController.java

@RequestMapping("/kill")
public String secKill(Model model, User user, @RequestParam("goodsId") Long goodsId) {
    if (user == null) {
        return "login";
    }
    model.addAttribute("user", user);

    GoodsVO goods = goodsService.getGoodsDetailById(goodsId);  // 《第一處》
    // 判斷庫存
    if (goods.getStockCount() <= 0) {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_OVER.getMsg());
        return "kill_fail";
    }
    // 判斷是否已經秒殺到商品,防止一人多次秒殺成功
    SecKillOrder order = orderService.getSecKillOrderByUserIdAndGoodsId(user.getId(), goodsId);  // 《第二處》
    if (order != null) {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_REPEAT.getMsg());
        return "kill_fail";
    }

    // 正常進入秒殺流程:1.減少庫存,2.創建訂單,3-寫入秒殺訂單 三步需要原子操作
    OrderInfo orderInfo = secKillService.secKill(user, goods);

    // 進入訂單詳情頁
    model.addAttribute("orderInfo", orderInfo);
    model.addAttribute("goods", goods);
    return "order_detail";
}

//SecKillService.java

/**
 * 減少庫存,下訂單,寫入秒殺訂單
 * 需要用事務保證原子性
 * @param user
 * @param goods
 * @return
 */
@Transactional
public OrderInfo secKill(User user, GoodsVO goods) {
    // 減少庫存
    goodsService.reduceStock(goods);
    // 秒殺下單
    return orderService.createSecKillOrder(user, goods);
}

減少庫存的 Service 層和 Dao 層方法如下:

//GoodsService.java

/**
 * 減小庫存
 * @param goods 對goods進行的庫存進行更新
 */
public int reduceStock(GoodsVO goods) {
    SecKillGoods g = new SecKillGoods();
    g.setGoodsId(goods.getId());
    g.setStockCount(goods.getStockCount() - 1);  // 設置庫存值
    return goodsDao.updateStock(g);
}

// GoodsDao.java

/**
 * 更新庫存
 * @param g
 * @return
 */
@Update("UPDATE miaosha_goods SET stock_count = #{stockCount} WHERE goods_id = #{goodsId}")
int updateStock(SecKillGoods g);

上面的實現方式在JMeter壓測下,果然發生了超賣現象,超賣到下了幾千個訂單。
商品超賣多次下單
原因是什麼呢?

我們分析控制層的代碼,第一處判斷商品庫存,多個線程獲取到商品庫存後可能執行時間片被其他線程搶佔,等到別的線程秒殺完,原來的線程並不會更新最新的庫存,而是使用的大於0的舊值,也就會繼續第二處判斷是否包含訂單。
同樣判斷是否有訂單,這裏對於大多數用戶來說確實是沒有訂單記錄的,因爲秒殺成功的只有少數人。就算有記錄,和第一處一樣,同一個用戶連續請求兩次,第一次判斷沒有記錄,然後線程等待,第二次也判斷沒有記錄,線程也等待,然後第一次的線程繼續下單秒殺成功,此時對於第二個線程還是不知道的,會繼續下單,重複秒殺。
至於我們扣減庫存,下單的邏輯也僅僅只是保證了操作的原子性,並沒有保證商品超賣現象不會發生。由於第一步多線程獲取秒殺商品的信息不是最新的,這樣在 g.setStockCount(goods.getStockCount() - 1); 設置更新庫存值時也不是最新的,所以纔會出現那麼多超賣訂單的出現。

初步解決超賣問題:

我們先來解決更新的庫存值不是最新的問題,其實我們完全可以利用數據庫來解決這個問題,每次來一個下單請求,我們將數據庫中的庫存減1即可,數據庫的悲觀鎖會保證我們庫存數是不斷減1的。

/**
 * 減小庫存
 * @param goods 對goods進行的庫存進行更新
 */
public int reduceStock(GoodsVO goods) {
    SecKillGoods g = new SecKillGoods();
    g.setGoodsId(goods.getId());
    return goodsDao.updateStock(g);
}

/**
 * 更新庫存
 * @param g
 * @return
 */
@Update("UPDATE miaosha_goods SET stock_count = stock_count - 1 WHERE goods_id = #{goodsId}")
int updateStock(SecKillGoods g);

再次壓測,問題仍然存在。
仍然存在商品超賣問題
下單數量超賣
解決庫存爲負數的問題:

我們已經保證了每次庫存的更新是嚴格按照每次減1的順序進行更新的,那麼還會超賣說明是我們確切的有超過數量的線程數成功經過前面兩步的判斷放行過來的。那麼自然的我們在寫多線程程序的時候可能會想到加鎖等保證可見性,保證前兩步的判斷不會出錯,但其實我們的最終目標只是不要超賣,保證固定數量的線程數成功即可。所以我們可以在數據庫更新庫存的時候增加判斷條件 stock_count > 0

/**
*更新庫存,解決商品超賣問題的關鍵所在
 * @param g
 * @return
 */
@Update("UPDATE miaosha_goods SET stock_count = stock_count - 1 WHERE goods_id = #{goodsId} AND stock_count > 0")
int updateStock(SecKillGoods g);

這樣就可以確保我們的庫存不會變成負數,利用的是數據庫的悲觀鎖機制。

但是壓測時,我們發現,創建的訂單數還是超了,這是爲什麼呢?

我們的減庫存、下單代碼如下:

/**
 * 減少庫存,下訂單,寫入秒殺訂單
 * 需要用事務保證原子性,默認傳播方式是REQUIRED,沒事務創建事務,有事務事務
 * @param user
 * @param goods
 * @return
 */
@Transactional
public OrderInfo secKill(User user, GoodsVO goods) {
    // 減少庫存
    int updateRows = goodsService.reduceStock(goods);
    logger.info("當前庫存爲:" + goods.getStockCount() + ",減少庫存更新記錄數爲:" + updateRows);
    // 秒殺下單
    return orderService.createSecKillOrder(user, goods);
}

減庫存我們用的是數據庫保證了數據庫層面數據的正確性,但是代碼裏仍然是存在問題的。我們查看日誌信息,可以清晰的發現,後面庫存實際爲0的時候,goods 對象的庫存數還是之前大於0的舊值。其實減少庫存的操作已經沒有成功,但是我們沒有判斷,所以繼續下單導致超賣。
併發線程庫存沒有問題,下單有問題
解決下單超出問題:

解決這個問題僅僅需要保證數據庫減庫存操作成功,便可以創建訂單,否則不創建訂單。

// SecKillController.java

@RequestMapping("/kill")
public String secKill(Model model, User user, @RequestParam("goodsId") Long goodsId) {
    if (user == null) {
        return "login";
    }
    model.addAttribute("user", user);

    GoodsVO goods = goodsService.getGoodsDetailById(goodsId);
    // 判斷庫存
    if (goods.getStockCount() <= 0) {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_OVER.getMsg());
        return "kill_fail";
    }
    // 判斷是否已經秒殺到商品,防止一人多次秒殺成功
    SecKillOrder order = orderService.getSecKillOrderByUserIdAndGoodsId(user.getId(), goodsId);
    if (order != null) {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_REPEAT.getMsg());
        return "kill_fail";
    }

    // 正常進入秒殺流程:1.減少庫存,2.創建訂單,3-寫入秒殺訂單 三步需要原子操作
    OrderInfo orderInfo = secKillService.secKill(user, goods);

    if (orderInfo != null) {
        // 進入訂單詳情頁
        model.addAttribute("orderInfo", orderInfo);
        model.addAttribute("goods", goods);
        return "order_detail";
    } else {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_OVER.getMsg());
        return "kill_fail";
    }
}

//GoodsService.java

/**
 * 減少庫存,下訂單,寫入秒殺訂單
 * 需要用事務保證原子性,默認傳播方式是REQUIRED,沒事務創建事務,有事務事務
 * @param user
 * @param goods
 * @return
 */
@Transactional
public OrderInfo secKill(User user, GoodsVO goods) {
    // 減少庫存
    int updateRows = goodsService.reduceStock(goods);
    logger.info("當前庫存爲:" + goods.getStockCount() + ",減少庫存更新記錄數爲:" + updateRows);
    // 減少庫存成功才進行秒殺下單
    if (updateRows == 1) {
        return orderService.createSecKillOrder(user, goods);
    }
    // 如果庫存沒有更新成功,則不能進行下單
    else {
        return null;
    }
}

再重新進行壓測,便解決商品超賣問題。
創建秒殺訂單成功
問題真的解決了嗎?

壓測時,我們縮減爲3個用戶多次請求進行搶購。我們可以看到用戶重複秒殺同一商品的情形。爲什麼出現呢,我們之前已經分析過了,判斷用戶是否已經秒殺到商品是線程不安全的,而等到後面進行秒殺下單時也沒有再進行判斷。
用戶重複秒殺問題
如果判斷用戶是否重複秒殺呢,其實我們可以給用戶與商品添加上一個組合唯一索引,這樣重複創建訂單就會插入紀錄失敗,這樣由於事務的存在,庫存減少也會回滾。

解決重複秒殺問題:

create index userid_goodsid_unique_index on miaosha_order(user_id, goods_id);

再次壓測結果:
正確創建訂單
正確減少庫存
這次我們看到訂單記錄也是沒有問題的。

IV. 秒殺接口優化

上面實現商品不超賣的方案是從數據庫悲觀鎖層面保證的,每一次秒殺請求都會進行數據庫的訪問,這其實在秒殺場景下是扛不住訪問壓力的。我們可以採用Redis預減庫存+MQ異步下單+接口限流的方案提高系統的性能,緩和瞬時請求量龐大的問題。

首先訪問內存標記,是否已經秒殺結束。然後通過Redis預減庫存的方式放行庫存數量的請求通過,接下來再採取之前的方式通過數據庫保證不超賣。

// SecKillController.java

@Autowired
private MQSender mqSender;
@Autowired
private RedisTemplate<String, Object> redisTemplate;

/**
 * 內存標記Map,key爲商品id,value爲是否秒殺結束,用於減少對Redis的訪問
 * 該內存標記非線程安全,但不會影響功能,只是有多個線程多次複寫某商品賣完
 */
private HashMap<Long, Boolean> goodsSecKillOverMap = new HashMap<>();

@RequestMapping("/asynckill")
public String asyncSecKill(Model model, User user, @RequestParam("goodsId") Long goodsId) {
    if (user == null) {
        return "login";
    }
    model.addAttribute("user", user);

    // 先訪問內存標記,查看是否賣完
    if (goodsSecKillOverMap.containsKey(goodsId) && goodsSecKillOverMap.get(goodsId)) {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_OVER.getMsg());
        return "kill_fail";
    }

    // Redis預減庫存
    Long nowStock = redisTemplate.opsForValue().decrement(GoodsKey.goodsStockKey.getPrefix() + ":" + goodsId);
    logger.info("商品" + goodsId + "預減庫存完Redis當前庫存數量爲:" + nowStock);

    // 如果庫存預減完畢,則直接返回秒殺失敗
    if (nowStock < 0) {
        // 記錄當前商品秒殺完畢
        goodsSecKillOverMap.put(goodsId, true);
        model.addAttribute("errorMsg", CodeMsg.SECKILL_OVER.getMsg());
        return "kill_fail";
    }

    // 判斷是否已經秒殺到商品,防止一人多次秒殺成功
    SecKillOrder order = orderService.getSecKillOrderByUserIdAndGoodsId(user.getId(), goodsId);
    if (order != null) {
        model.addAttribute("errorMsg", CodeMsg.SECKILL_REPEAT.getMsg());
        return "kill_fail";
    }

    // 正常進入秒殺流程:入隊進行異步下單
    mqSender.sendSecKillMessage(new SecKillMessage(user, goodsId));
    model.addAttribute("killMsg", CodeMsg.SECKILL_WAITTING.getMsg());
    return "kill_wait";
}

「異步下單」

下單的時候這裏僅僅是通過發送一個下單請求消息進入隊列,然後立即返回排隊中的結果給用戶。異步下單的方式,將系統的瞬時請求進行削峯。

//MQSender.java

/**
 * 發送秒殺請求消息,包含用戶和商品id
 * @param secKillMessage
 */
public void sendSecKillMessage(SecKillMessage secKillMessage) {
    logger.info("用戶" + secKillMessage.getUser().getId() + "發起秒殺" + secKillMessage.getGoodsId() + "商品請求");
    rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE, secKillMessage);
}

//MQReceiver.java
@RabbitHandler
public void receiveSecKillMessage(@Payload SecKillMessage secKillMessage) {
    User user = secKillMessage.getUser();
    Long goodsId = secKillMessage.getGoodsId();

    logger.info("收到用戶" + user.getId() + "秒殺" + goodsId + "商品請求");

    // 判斷庫存
    GoodsVO goods = goodsService.getGoodsDetailById(goodsId);
    if (goods.getStockCount() <= 0) {
        return;
    }
    // 判斷是否已經秒殺到商品,防止一人多次秒殺成功
    SecKillOrder order = orderService.getSecKillOrderByUserIdAndGoodsId(user.getId(), goodsId);
    if (order != null) {
        return;
    }

    // 正常進入秒殺流程:1.減少庫存,2.創建訂單,3-寫入秒殺訂單 三步需要原子操作
    OrderInfo orderInfo = secKillService.secKill(user, goods);

    if (orderInfo != null) {
        // 秒殺成功

    } else {
        // 秒殺失敗
    }
}

利用JMeter壓測時打印的日誌如下:

INFO 70092 --- [o-8080-exec-124] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:5
INFO 70092 --- [o-8080-exec-168] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:6
INFO 70092 --- [nio-8080-exec-2] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:4
INFO 70092 --- [o-8080-exec-194] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:3
INFO 70092 --- [io-8080-exec-76] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:2
INFO 70092 --- [o-8080-exec-130] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:1
INFO 70092 --- [o-8080-exec-138] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:0
INFO 70092 --- [io-8080-exec-26] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-1
INFO 70092 --- [io-8080-exec-79] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-2
INFO 70092 --- [io-8080-exec-14] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-3
INFO 70092 --- [o-8080-exec-201] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-4
INFO 70092 --- [o-8080-exec-204] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-5
INFO 70092 --- [o-8080-exec-202] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-6
INFO 70092 --- [o-8080-exec-205] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-7
INFO 70092 --- [o-8080-exec-206] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-8
INFO 70092 --- [o-8080-exec-221] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-9
INFO 70092 --- [o-8080-exec-225] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-10
INFO 70092 --- [o-8080-exec-211] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-11
INFO 70092 --- [o-8080-exec-224] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-12
INFO 70092 --- [o-8080-exec-220] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-14
INFO 70092 --- [o-8080-exec-208] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-13
INFO 70092 --- [o-8080-exec-207] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-15
INFO 70092 --- [o-8080-exec-218] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-16
INFO 70092 --- [o-8080-exec-222] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-17
INFO 70092 --- [o-8080-exec-219] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-18
INFO 70092 --- [o-8080-exec-223] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-19
INFO 70092 --- [o-8080-exec-216] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-20
INFO 70092 --- [o-8080-exec-217] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-21
INFO 70092 --- [o-8080-exec-213] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-22
INFO 70092 --- [o-8080-exec-215] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-23
INFO 70092 --- [o-8080-exec-212] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-24
INFO 70092 --- [o-8080-exec-214] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-25
INFO 70092 --- [o-8080-exec-203] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-26
INFO 70092 --- [o-8080-exec-210] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-27
INFO 70092 --- [o-8080-exec-209] SecKillController  : 商品2預減庫存完Redis當前庫存數量爲:-28
INFO 70092 --- [o-8080-exec-124] MQSender           : 用戶13000000004發起秒殺2商品請求
INFO 70092 --- [o-8080-exec-168] MQSender           : 用戶13000000001發起秒殺2商品請求
INFO 70092 --- [nio-8080-exec-2] MQSender           : 用戶13000000000發起秒殺2商品請求
INFO 70092 --- [o-8080-exec-194] MQSender           : 用戶13000000006發起秒殺2商品請求
INFO 70092 --- [o-8080-exec-130] MQSender           : 用戶13000000005發起秒殺2商品請求
INFO 70092 --- [o-8080-exec-138] MQSender           : 用戶13000000003發起秒殺2商品請求
INFO 70092 --- [io-8080-exec-76] MQSender           : 用戶13000000002發起秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000002秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:7,減少庫存更新記錄數爲:1
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000005秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:6,減少庫存更新記錄數爲:1
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000003秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:5,減少庫存更新記錄數爲:1
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000004秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:4,減少庫存更新記錄數爲:1
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000001秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:3,減少庫存更新記錄數爲:1
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000006秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:2,減少庫存更新記錄數爲:1
INFO 70092 --- [cTaskExecutor-1] MQReceiver         : 收到用戶13000000000秒殺2商品請求
INFO 70092 --- [cTaskExecutor-1] SecKillService     : 當前庫存爲:1,減少庫存更新記錄數爲:1

「接口限流」

通過自定義註解+Spring方法攔截器+Redis訪問次數統計的方式,可以對需要限流訪問的接口進行限流。

自定義接口 @AccessLimit

/**
 * @description: 請求限流注解
 * @author: guoping wang
 * @email: [email protected]
 * @date: 2019/5/10 2:11 PM
 * @project: seckill
 */
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface AccessLimit {

    /**
     * 指定時間
     * @return
     */
    int seconds() default 60;

    /**
     * seconds時間內最多允許訪問maxValue次數
     * @return
     */
    int maxValue();

    /**
     * 該請求是否需要登錄
     * @return
     */
    boolean needLogin() default true;
}

Spring攔截器對 @AccessLimit 的處理:

/**
 * @description: 自定義限流攔截器
 * @author: guoping wang
 * @email: [email protected]
 * @date: 2019/5/10 2:16 PM
 * @project: seckill
 */
@Component
public class AccessInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 方法執行前,對方法進行攔截查看是否包含@AccessLimit註解,並進行相應的設置
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            // 獲取用戶並保存到ThreadLocal中
            User user = getUser(request);
            UserContext.setUser(user);

            HandlerMethod handlerMethod = (HandlerMethod) handler;
            // 獲取方法上@AccessLimit的註解
            AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
            if (accessLimit == null) {
                return true;
            }
            // 存在@AccessLimit註解,則需要對註解配置進行獲取並進行處理
            int maxValue = accessLimit.maxValue();
            int seconds = accessLimit.seconds();
            boolean needLogin = accessLimit.needLogin();

            String key = request.getRequestURI();

            if (needLogin && UserContext.getUser() == null) {
                render(response, CodeMsg.SESSION_ERROR);
                return false;
            }

            // 不需要登錄則根據是否包含user來拼接key
            key += "_" + (UserContext.getUser() != null ? user.getId() : "");

            // 利用Redis實現限流
            AccessLimitKey accessLimitKey = AccessLimitKey.getAccessKeyWithExpire(seconds);
            String accessKey = accessLimitKey.getPrefix() + ":" + key;
            Integer count = (Integer) redisTemplate.opsForValue().get(accessKey);
            if (count == null) {
                redisTemplate.opsForValue().set(accessKey, 1);
                redisTemplate.expire(accessKey, accessLimitKey.expireSeconds(), TimeUnit.SECONDS);
            }
            // 仍然在限制內
            else if (count <= maxValue) {
                redisTemplate.opsForValue().increment(accessKey);
            }
            // 超出最大訪問次數限制
            else {
                render(response, CodeMsg.ACCESS_BEYOND_LIMIT);
                return false;
            }
        }
        return true;
    }

    /**
     * 返回頁面,被攔截器攔截需要登錄卻沒有登錄
     * @param response
     * @param codeMsg
     */
    private void render(HttpServletResponse response, CodeMsg codeMsg) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        OutputStream outputStream = response.getOutputStream();
        String out = JSON.toJSONString(ServerResponse.error(codeMsg));
        outputStream.write(out.getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
    }

    public User getUser(HttpServletRequest request) {
        String paramToken = request.getParameter(UserService.COOKIE_TOKEN_NAME);
        String cookieToken = getCookieValue(request, UserService.COOKIE_TOKEN_NAME);
        if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return null;
        }
        // 優先從參數中獲取用戶token
        String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
        // 根據Token查詢User
        return userService.getUserByToken(token);
    }


    /**
     * 根據Cookie的名稱獲取對應的值
     * @param request
     * @param name
     * @return
     */
    private String getCookieValue(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        // 壓測下發現問題,cookies可能爲空
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章