秒殺架構實踐

前言

以前在 Java-Interview 中提到過秒殺架構的設計,此次基於其中的理論簡單實現了一下。前端

本次採用按部就班的方式逐步提升性能達到併發秒殺的效果,文章較長請準備好瓜子板凳(liushuizhang😂)。java

本文全部涉及的代碼:git

最終架構圖:github

系統架構設計.png

先簡單根據這個圖談下請求的流轉,由於後面無論怎麼改進這個都是沒有變的。web

  • 前端請求進入 web 層,對應的代碼就是 controller
  • 以後將真正的庫存校驗、下單等請求發往 Service 層(其中 RPC 調用依然採用的 dubbo,只是更新爲最新版本,本次不會過多討論 dubbo 相關的細節,有興趣的能夠查看 基於dubbo的分佈式架構)。
  • Service 層再對數據進行落地,下單完成。

無限制

其實拋開秒殺這個場景來講正常的一個下單流程能夠簡單分爲如下幾步:redis

  • 校驗庫存
  • 扣庫存
  • 建立訂單
  • 支付

基於上文的架構因此咱們有了如下實現:sql

先看看實際項目的結構:shell

仍是和之前同樣:數據庫

  • 提供出一個 API 用於 Service 層實現,以及 web 層消費。
  • web 層簡單來講就是一個 SpringMVC
  • Service 層則是真正的數據落地。
  • SSM-SECONDS-KILL-ORDER-CONSUMER 則是後文會提到的 Kafka 消費。

數據庫也是隻有簡單的兩張表模擬下單:緩存

CREATE TABLE `stock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名稱',
  `count` int(11) NOT NULL COMMENT '庫存',
  `sale` int(11) NOT NULL COMMENT '已售',
  `version` int(11) NOT NULL COMMENT '樂觀鎖,版本號',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;


CREATE TABLE `stock_order` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `sid` int(11) NOT NULL COMMENT '庫存ID',
  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名稱',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '建立時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;

web 層 controller 實現:

@Autowired
    private StockService stockService;

    @Autowired
    private OrderService orderService;
    
    @RequestMapping("/createWrongOrder/{sid}")
    @ResponseBody
    public String createWrongOrder(@PathVariable int sid) {
        logger.info("sid=[{}]", sid);
        int id = 0;
        try {
            id = orderService.createWrongOrder(sid);
        } catch (Exception e) {
            logger.error("Exception",e);
        }
        return String.valueOf(id);
    }

其中 web 做爲一個消費者調用看 OrderService 提供出來的 dubbo 服務。

Service 層,OrderService 實現:

首先是對 API 的實現(會在 API 提供出接口):

@Service
public class OrderServiceImpl implements OrderService {

    @Resource(name = "DBOrderService")
    private com.crossoverJie.seconds.kill.service.OrderService orderService ;

    @Override
    public int createWrongOrder(int sid) throws Exception {
        return orderService.createWrongOrder(sid);
    }
}

這裏只是簡單調用了 DBOrderService 中的實現,DBOrderService 纔是真正的數據落地,也就是寫數據庫了。

DBOrderService 實現:

Transactional(rollbackFor = Exception.class)
@Service(value = "DBOrderService")
public class OrderServiceImpl implements OrderService {
    @Resource(name = "DBStockService")
    private com.crossoverJie.seconds.kill.service.StockService stockService;

    @Autowired
    private StockOrderMapper orderMapper;
    
    @Override
    public int createWrongOrder(int sid) throws Exception{

        //校驗庫存
        Stock stock = checkStock(sid);

        //扣庫存
        saleStock(stock);

        //建立訂單
        int id = createOrder(stock);

        return id;
    }
    
    private Stock checkStock(int sid) {
        Stock stock = stockService.getStockById(sid);
        if (stock.getSale().equals(stock.getCount())) {
            throw new RuntimeException("庫存不足");
        }
        return stock;
    }
    
    private int saleStock(Stock stock) {
        stock.setSale(stock.getSale() + 1);
        return stockService.updateStockById(stock);
    }
    
    private int createOrder(Stock stock) {
        StockOrder order = new StockOrder();
        order.setSid(stock.getId());
        order.setName(stock.getName());
        int id = orderMapper.insertSelective(order);
        return id;
    }        
        
}

預先初始化了 10 條庫存。

手動調用下 createWrongOrder/1 接口發現:

庫存表:

訂單表:

一切看起來都沒有問題,數據也正常。

可是當用 JMeter 併發測試時:

測試配置是:300個線程併發,測試兩輪來看看數據庫中的結果:

請求都響應成功,庫存確實也扣完了,可是訂單卻生成了 124 條記錄。

這顯然是典型的超賣現象。

其實如今再去手動調用接口會返回庫存不足,但爲時晚矣。

樂觀鎖更新

怎麼來避免上述的現象呢?

最簡單的作法天然是樂觀鎖了,這裏不過多討論這個,不熟悉的朋友能夠看下這篇

來看看具體實現:

其實其餘的都沒怎麼改,主要是 Service 層。

@Override
    public int createOptimisticOrder(int sid) throws Exception {

        //校驗庫存
        Stock stock = checkStock(sid);

        //樂觀鎖更新庫存
        saleStockOptimistic(stock);

        //建立訂單
        int id = createOrder(stock);

        return id;
    }
    
    private void saleStockOptimistic(Stock stock) {
        int count = stockService.updateStockByOptimistic(stock);
        if (count == 0){
            throw new RuntimeException("併發更新庫存失敗") ;
        }
    }

對應的 XML:

<update id="updateByOptimistic" parameterType="com.crossoverJie.seconds.kill.pojo.Stock">
        update stock
        <set>
            sale = sale + 1,
            version = version + 1,
        </set>

        WHERE id = #{id,jdbcType=INTEGER}
        AND version = #{version,jdbcType=INTEGER}

    </update>

一樣的測試條件,咱們再進行上面的測試 /createOptimisticOrder/1

此次發現不管是庫存訂單都是 OK 的。

查看日誌發現:

不少併發請求會響應錯誤,這就達到了效果。

提升吞吐量

爲了進一步提升秒殺時的吞吐量以及響應效率,這裏的 web 和 Service 都進行了橫向擴展。

  • web 利用 Nginx 進行負載。
  • Service 也是多臺應用。

再用 JMeter 測試時能夠直觀的看到效果。

因爲我是在阿里雲的一臺小水管服務器進行測試的,加上配置不高、應用都在同一臺,因此並無徹底體現出性能上的優點( Nginx 作負載轉發時候也會增長額外的網絡消耗)。

shell 腳本實現簡單的 CI

因爲應用多臺部署以後,手動發版測試的痛苦相信經歷過的都有體會。

此次並無精力去搭建完整的 CI CD,只是寫了一個簡單的腳本實現了自動化部署,但願對這方面沒有經驗的同窗帶來一點啓發:

構建 web

#!/bin/bash

# 構建 web 消費者

#read appname

appname="consumer"
echo "input="$appname

PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')

# 遍歷殺掉 pid
for var in ${PID[@]};
do
    echo "loop pid= $var"
    kill -9 $var
done

echo "kill $appname success"

cd ..

git pull

cd SSM-SECONDS-KILL

mvn -Dmaven.test.skip=true clean package

echo "build war success"

cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/webapps
echo "cp tomcat-dubbo-consumer-8083/webapps ok!"

cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/webapps
echo "cp tomcat-dubbo-consumer-7083-slave/webapps ok!"

sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/bin/startup.sh
echo "tomcat-dubbo-consumer-8083/bin/startup.sh success"

sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/bin/startup.sh
echo "tomcat-dubbo-consumer-7083-slave/bin/startup.sh success"

echo "start $appname success"

構建 Service

# 構建服務提供者

#read appname

appname="provider"

echo "input="$appname


PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')

#if [ $? -eq 0 ]; then
#    echo "process id:$PID"
#else
#    echo "process $appname not exit"
#    exit
#fi

# 遍歷殺掉 pid
for var in ${PID[@]};
do
    echo "loop pid= $var"
    kill -9 $var
done

echo "kill $appname success"


cd ..

git pull

cd SSM-SECONDS-KILL

mvn -Dmaven.test.skip=true clean package

echo "build war success"

cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/webapps

echo "cp tomcat-dubbo-provider-8080/webapps ok!"

cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/webapps

echo "cp tomcat-dubbo-provider-7080-slave/webapps ok!"

sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/bin/startup.sh
echo "tomcat-dubbo-provider-8080/bin/startup.sh success"

sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/bin/startup.sh
echo "tomcat-dubbo-provider-8080/bin/startup.sh success"

echo "start $appname success"

以後每當我有更新,只須要執行這兩個腳本就能夠幫我自動構建。

都是最基礎的 Linux 命令,相信你們都看得明白。

樂觀鎖更新 + 分佈式限流

上文的結果看似沒有問題,其實還差得遠呢。

這裏只是模擬了 300 個併發沒有問題,可是當請求達到了 3000 ,3W,300W 呢?

雖然說能夠橫向擴展能夠支撐更多的請求。

可是能不能利用最少的資源解決問題呢?

其實仔細分析下會發現:

假設個人商品一共只有 10 個庫存,那麼不管你多少人來買其實最終也最多隻有 10 人能夠下單成功。

因此其中會有 99% 的請求都是無效的。

你們都知道:大多數應用數據庫都是壓倒駱駝的最後一根稻草。

經過 Druid 的監控來看看以前請求數據庫的狀況:

由於 Service 是兩個應用。

數據庫也有 20 多個鏈接。

怎麼樣來優化呢? 其實很容易想到的就是分佈式限流

咱們將併發控制在一個可控的範圍以內,而後快速失敗這樣就能最大程度的保護系統。

distributed-redis-tool ⬆️v1.0.3

爲此還對 https://github.com/crossoverJie/distributed-redis-tool 進行了小小的升級。

由於加上該組件以後全部的請求都會通過 Redis,因此對 Redis 資源的使用也是要很是當心。

API 更新

修改以後的 API 以下:

@Configuration
public class RedisLimitConfig {

    private Logger logger = LoggerFactory.getLogger(RedisLimitConfig.class);

    @Value("${redis.limit}")
    private int limit;


    @Autowired
    private JedisConnectionFactory jedisConnectionFactory;

    @Bean
    public RedisLimit build() {
        RedisLimit redisLimit = new RedisLimit.Builder(jedisConnectionFactory, RedisToolsConstant.SINGLE)
                .limit(limit)
                .build();

        return redisLimit;
    }
}

這裏構建器改用了 JedisConnectionFactory,因此得配合 Spring 來一塊兒使用。

並在初始化時顯示傳入 Redis 是以集羣方式部署仍是單機(強烈建議集羣,限流以後對 Redis 仍是有必定的壓力)。

限流實現

既然 API 更新了,實現天然也要修改:

/**
     * limit traffic
     * @return if true
     */
    public boolean limit() {

        //get connection
        Object connection = getConnection();

        Object result = limitRequest(connection);

        if (FAIL_CODE != (Long) result) {
            return true;
        } else {
            return false;
        }
    }

    private Object limitRequest(Object connection) {
        Object result = null;
        String key = String.valueOf(System.currentTimeMillis() / 1000);
        if (connection instanceof Jedis){
            result = ((Jedis)connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
            ((Jedis) connection).close();
        }else {
            result = ((JedisCluster) connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
            try {
                ((JedisCluster) connection).close();
            } catch (IOException e) {
                logger.error("IOException",e);
            }
        }
        return result;
    }

    private Object getConnection() {
        Object connection ;
        if (type == RedisToolsConstant.SINGLE){
            RedisConnection redisConnection = jedisConnectionFactory.getConnection();
            connection = redisConnection.getNativeConnection();
        }else {
            RedisClusterConnection clusterConnection = jedisConnectionFactory.getClusterConnection();
            connection = clusterConnection.getNativeConnection() ;
        }
        return connection;
    }

若是是原生的 Spring 應用得采用 @SpringControllerLimit(errorCode = 200) 註解。

實際使用以下:

web 端:

/**
     * 樂觀鎖更新庫存 限流
     * @param sid
     * @return
     */
    @SpringControllerLimit(errorCode = 200)
    @RequestMapping("/createOptimisticLimitOrder/{sid}")
    @ResponseBody
    public String createOptimisticLimitOrder(@PathVariable int sid) {
        logger.info("sid=[{}]", sid);
        int id = 0;
        try {
            id = orderService.createOptimisticOrder(sid);
        } catch (Exception e) {
            logger.error("Exception",e);
        }
        return String.valueOf(id);
    }

Service 端就沒什麼更新了,依然是採用的樂觀鎖更新數據庫。

再壓測看下效果 /createOptimisticLimitOrderByRedis/1

首先是看結果沒有問題,再看數據庫鏈接以及併發請求數都有明顯的降低

樂觀鎖更新 + 分佈式限流 + Redis 緩存

其實仔細觀察 Druid 監控數據發現這個 SQL 被屢次查詢:

其實這是實時查詢庫存的 SQL,主要是爲了在每次下單以前判斷是否還有庫存。

這也是個優化點

這種數據咱們徹底能夠放在內存中,效率比在數據庫要高不少。

因爲咱們的應用是分佈式的,因此堆內緩存顯然不合適,Redis 就很是適合。

此次主要改造的是 Service 層:

  • 每次查詢庫存時走 Redis。
  • 扣庫存時更新 Redis。
  • 須要提早將庫存信息寫入 Redis(手動或者程序自動均可以)。

主要代碼以下:

@Override
    public int createOptimisticOrderUseRedis(int sid) throws Exception {
        //檢驗庫存,從 Redis 獲取
        Stock stock = checkStockByRedis(sid);

        //樂觀鎖更新庫存 以及更新 Redis
        saleStockOptimisticByRedis(stock);

        //建立訂單
        int id = createOrder(stock);
        return id ;
    }
    
    
    private Stock checkStockByRedis(int sid) throws Exception {
        Integer count = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_COUNT + sid));
        Integer sale = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_SALE + sid));
        if (count.equals(sale)){
            throw new RuntimeException("庫存不足 Redis currentCount=" + sale);
        }
        Integer version = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_VERSION + sid));
        Stock stock = new Stock() ;
        stock.setId(sid);
        stock.setCount(count);
        stock.setSale(sale);
        stock.setVersion(version);

        return stock;
    }    
    
    
    /**
     * 樂觀鎖更新數據庫 還要更新 Redis
     * @param stock
     */
    private void saleStockOptimisticByRedis(Stock stock) {
        int count = stockService.updateStockByOptimistic(stock);
        if (count == 0){
            throw new RuntimeException("併發更新庫存失敗") ;
        }
        //自增
        redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_SALE + stock.getId(),1) ;
        redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_VERSION + stock.getId(),1) ;
    }

壓測看看實際效果 /createOptimisticLimitOrderByRedis/1

最後發現數據沒問題,數據庫的請求與併發也都下來了。

樂觀鎖更新 + 分佈式限流 + Redis 緩存 + Kafka 異步

最後的優化仍是想如何來再次提升吞吐量以及性能的。

咱們上文全部例子其實都是同步請求,徹底能夠利用同步轉異步來提升性能啊。

這裏咱們將寫訂單以及更新庫存的操做進行異步化,利用 Kafka 來進行解耦和隊列的做用。

每當一個請求經過了限流到達了 Service 層經過了庫存校驗以後就將訂單信息發給 Kafka ,這樣一個請求就能夠直接返回了。

消費程序再對數據進行入庫落地。

由於異步了,因此最終須要採起回調或者是其餘提醒的方式提醒用戶購買完成。

這裏代碼較多就不貼了,消費程序其實就是把以前的 Service 層的邏輯重寫了一遍,不過採用的是 SpringBoot。

感興趣的朋友能夠看下。

https://github.com/crossoverJie/SSM/tree/master/SSM-SECONDS-KILL/SSM-SECONDS-KILL-ORDER-CONSUMER

總結

其實通過上面的一頓優化總結起來無非就是如下幾點:

  • 儘可能將請求攔截在上游。
  • 還能夠根據 UID 進行限流。
  • 最大程度的減小請求落到 DB。
  • 多利用緩存。
  • 同步操做異步化。
  • fail fast,儘早失敗,保護應用。

碼字不易,這應該是我寫過字數最多的了,想一想當年高中 800 字的做文都憋不出來😂,可想而知是有多可貴了。

以上內容歡迎討論

號外

最近在總結一些 Java 相關的知識點,感興趣的朋友能夠一塊兒維護。

地址: https://github.com/crossoverJie/Java-Interview

相關文章
相關標籤/搜索