秒殺アーキテクチャの実践

28943 ワード

前言


以前Java-Interviewで秒殺アーキテクチャの設計に言及していましたが、今回はその理論に基づいて簡単に実現しました.
今回は順次漸進的に性能を向上させ、同時秒殺の効果を達成します.文章が長いので、瓜のベンチ(liushuizhang?)を用意してください.
本明細書のすべてのコード:
  • github.com/crossoverJi…
  • github.com/crossoverJi…

  • 最終アーキテクチャ図:
    まず、この図に基づいて要求の流れを簡単に話します.後でいくら改善しても変わらないからです.
  • フロントエンド要求はweb層に入り、対応するコードはcontrollerである.
  • 以降、本格的な在庫チェック、注文などのリクエストをService階層に送信します(RPC呼び出しは依然として採用されているdubboですが、最新バージョンに更新されているだけで、今回はdubboに関する詳細はあまり議論されず、興味のある方はdubboベースの分散アーキテクチャを参照してください).
  • Service階でデータを再着地し、注文が完了した.

  • 制限なし


    実は秒殺というシーンを捨てて、普通の注文の流れは簡単に以下のステップに分けることができます.
  • 検査在庫
  • インベントリ
  • オーダー作成
  • 支払
  • 上記のアーキテクチャに基づいて、次のような実装が行われました.
    まず、実際のプロジェクトの構造を見てみましょう.
    以前と同じです.
  • は、API層の実装のためのServiceを提供し、web層の消費を提供する.
  • web層は簡単にはSpringMVCです.
  • Service層は本当のデータが到着した.
  • SSM-SECONDS-KILL-ORDER-CONSUMERは、後述するKafka消費である.

  • データベースも単純な2つのテーブルのみが注文をシミュレートします.
    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サービスを消費者として呼び出している.
    サービス層、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スレッドが同時に実行され、2ラウンドのテストでデータベースの結果を確認します.
    リクエストはすべて応答に成功し、在庫も確かに差し引かれたが、注文は124件の記録を生成した.
    これは明らかに典型的な超過販売現象である.
    実は今から手動でインタフェースを呼び出すと在庫不足に戻りますが、もう遅いです.

    楽観ロックの更新


    上記の現象を避けるにはどうすればいいのでしょうか.
    最も簡単な方法は自然に楽観的にロックされていますが、ここではこれについて議論しすぎません.よく知らない友达はこれを見ることができます.
    具体的な実装を見てみましょう.
    実は他はあまり変わっていませんが、主にサービス層です.
        @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とサービスを横方向に拡張した.
  • 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"
    

    サービスの構築

    #  
    
    #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"
    

    その後、更新するたびに、この2つのスクリプトを実行するだけで自動的に構築できます.
    すべて最も基礎的なLinux命令で、みんながすべてはっきり見えることを信じます.

    楽観ロック更新+分散制限フロー


    上記の結果は問題ないように見えますが、実はまだまだですね.
    ここでは300個の同時シミュレーションを行っただけで問題はありませんが、要求が3000,3 W,300 Wに達した場合は?
    横に広がることができるとはいえ、より多くのリクエストを支えることができます.
    しかし、最小限の資源を利用して問題を解決することができますか?
    実際によく分析すると、
    もし私の商品が全部で10個の在庫しかないとしたら、あなたが何人で買っても、最終的には最大10人で注文に成功することができます.
    そのため、99%のリクエストは無効になります.
    ほとんどのアプリケーションデータベースはラクダを圧倒する最後のわらであることはよく知られています.Druidのモニタリングにより、以前にデータベースが要求された状況を確認します.
    サービスは2つのアプリケーションだからです.
    データベースにも20以上の接続があります.
    どのように最適化しますか?実は考えやすいのが分布式限流です.
    私たちは同時に制御可能な範囲内に制御し、急速に失敗することで、システムを最大限に保護することができます.

    distributed-redis-tool ⬆️v1.0.3


    そのためgithub.com/crossoverJi...ちょっとしたアップグレードが行われました.
    このコンポーネントを追加すると、すべてのリクエストが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);
        }
    

    サービス側は更新がなく、依然として楽観的なロックを採用してデータベースを更新しています.
    再圧力測定による効果/createOptimisticLimitOrderByRedis/1:
    まず結果を見て問題なく,データベース接続および同時要求数が著しく減少した.

    楽観ロック更新+分散ストリーム制限+Redisキャッシュ


    実はDruidの監視データをよく見てみると、このSQLは何度もクエリーされています.
    実はこれはリアルタイムで在庫を調べるSQLで、主に注文するたびに在庫があるかどうかを判断するためです.
    これも最適化点です.
    このデータは、データベースよりも効率的にメモリに格納できます.
    我々のアプリケーションは分散型であるため,スタック内キャッシュは明らかに不適切であり,Redisは非常に適している.
    今回は主にサービス層を改造しました.
  • 在庫照会のたびに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を用いてデカップリングとキューの役割を果たす.
    リクエストが制限フローを通過してサービス・レイヤに到着するたびに在庫チェックを通過した後、受注情報をKafkaに送信し、リクエストを直接返すことができます.
    消費プログラムはデータを再入庫して着地する.
    非同期化されているため、最終的にはコールバックや他のアラートでユーザーに購入完了を通知する必要があります.
    ここはコードが多くて貼らないので、消費プログラムは実は前のサービス層の論理を書き直したのですが、SpringBootを採用しています.
    興味のある方はご覧ください.
    github.com/crossoverJi…

    まとめ


    実は上の最適化を経てまとめると、以下の点にほかならない.
  • は、要求をできるだけ上流にブロックする.
  • は、UIDに従ってストリーム制限を行うこともできる.
  • の最小化要求はDBに落ちる.
  • はキャッシュを多く利用しています.
  • 同期動作非同期化.
  • fail fast、できるだけ早く失敗し、アプリケーションを保護します.

  • コードワードは容易ではありません.これは私が書いた字数が一番多いはずです.高校の800字の作文を考えても我慢できませんか.どんなにありがたいことか想像できる.
    以上の内容は討論を歓迎します.

    号外


    最近はJavaに関する知識点をまとめていますが、興味のある方は一緒にメンテナンスできます.
    住所:github.com/crossoverJi…