簡(jiǎn)單幾步,設(shè)計(jì)一個(gè)高性能的秒殺系統(tǒng)!
本次采用循序漸進(jìn)的方式逐步提高性能達(dá)到并發(fā)秒殺的效果,文章較長(zhǎng),請(qǐng)準(zhǔn)備好瓜子板凳!
圖片來(lái)自包圖網(wǎng)
之前在 Java-Interview 中提到過(guò)秒殺架構(gòu)的設(shè)計(jì),這次基于其中的理論簡(jiǎn)單實(shí)現(xiàn)了一下。
本文所有涉及的代碼:
- https://github.com/crossoverJie/SSM
- https://github.com/crossoverJie/distributed-redis-tool
首先來(lái)看看最終架構(gòu)圖:
先簡(jiǎn)單根據(jù)這個(gè)圖談下請(qǐng)求的流轉(zhuǎn),因?yàn)楹竺娌还茉趺锤倪M(jìn),這些都是不變的:
- 前端請(qǐng)求進(jìn)入 Web 層,對(duì)應(yīng)的代碼就是 Controller。
- 之后將真正的庫(kù)存校驗(yàn)、下單等請(qǐng)求發(fā)往 Service 層,其中 RPC 調(diào)用依然采用的 Dubbo,只是更新為***版本。
- Service 層再對(duì)數(shù)據(jù)進(jìn)行落地,下單完成。
***制
拋開(kāi)秒殺這個(gè)場(chǎng)景來(lái)說(shuō),正常的一個(gè)下單流程可以簡(jiǎn)單分為以下幾步:
- 校驗(yàn)庫(kù)存
- 扣庫(kù)存
- 創(chuàng)建訂單
- 支付
基于上文的架構(gòu),我們有了以下實(shí)現(xiàn),先看看實(shí)際項(xiàng)目的結(jié)構(gòu):
還是和以前一樣:
- 提供出一個(gè) API 用于 Service 層實(shí)現(xiàn),以及 Web 層消費(fèi)。
- Web 層簡(jiǎn)單來(lái)說(shuō)就是一個(gè) Spring MVC。
- Service 層則是真正的數(shù)據(jù)落地。
- SSM-SECONDS-KILL-ORDER-CONSUMER 則是后文會(huì)提到的 Kafka 消費(fèi)。
數(shù)據(jù)庫(kù)也是只有簡(jiǎn)單的兩張表模擬下單:
- CREATE TABLE `stock` (
- `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
- `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名稱',
- `count` int(11) NOT NULL COMMENT '庫(kù)存',
- `sale` int(11) NOT NULL COMMENT '已售',
- `version` int(11) NOT NULL COMMENT '樂(lè)觀鎖,版本號(hào)',
- 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 '庫(kù)存ID',
- `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名稱',
- `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '創(chuàng)建時(shí)間',
- PRIMARY KEY (`id`)
- ) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;
Web 層 Controller 實(shí)現(xiàn):
- @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 作為一個(gè)消費(fèi)者調(diào)用看 OrderService 提供出來(lái)的 Dubbo 服務(wù)。
Service 層, OrderService 實(shí)現(xiàn),首先是對(duì) API 的實(shí)現(xiàn)(會(huì)在 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);
- }
- }
這里只是簡(jiǎn)單調(diào)用了 DBOrderService 中的實(shí)現(xiàn),DBOrderService 才是真正的數(shù)據(jù)落地,也就是寫(xiě)數(shù)據(jù)庫(kù)了。
DBOrderService 實(shí)現(xiàn):
- 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{
- //校驗(yàn)庫(kù)存
- Stock stock = checkStock(sid);
- //扣庫(kù)存
- saleStock(stock);
- //創(chuàng)建訂單
- 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("庫(kù)存不足");
- }
- 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;
- }
- }
預(yù)先初始化了 10 條庫(kù)存。手動(dòng)調(diào)用下 createWrongOrder/1 接口發(fā)現(xiàn):
庫(kù)存表
訂單表
一切看起來(lái)都沒(méi)有問(wèn)題,數(shù)據(jù)也正常。但是當(dāng)用 JMeter 并發(fā)測(cè)試時(shí):
測(cè)試配置是:300 個(gè)線程并發(fā)。測(cè)試兩輪來(lái)看看數(shù)據(jù)庫(kù)中的結(jié)果:
請(qǐng)求都響應(yīng)成功,庫(kù)存確實(shí)也扣完了,但是訂單卻生成了 124 條記錄。這顯然是典型的超賣現(xiàn)象。
其實(shí)現(xiàn)在再去手動(dòng)調(diào)用接口會(huì)返回庫(kù)存不足,但為時(shí)晚矣。
樂(lè)觀鎖更新
怎么來(lái)避免上述的現(xiàn)象呢?最簡(jiǎn)單的做法自然是樂(lè)觀鎖了,來(lái)看看具體實(shí)現(xiàn):
其實(shí)其他的都沒(méi)怎么改,主要是 Service 層:
- @Override
- public int createOptimisticOrder(int sid) throws Exception {
- //校驗(yàn)庫(kù)存
- Stock stock = checkStock(sid);
- //樂(lè)觀鎖更新庫(kù)存
- saleStockOptimistic(stock);
- //創(chuàng)建訂單
- int id = createOrder(stock);
- return id;
- }
- private void saleStockOptimistic(Stock stock) {
- int count = stockService.updateStockByOptimistic(stock);
- if (count == 0){
- throw new RuntimeException("并發(fā)更新庫(kù)存失敗") ;
- }
- }
對(duì)應(yīng)的 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>
同樣的測(cè)試條件,我們?cè)龠M(jìn)行上面的測(cè)試 /createOptimisticOrder/1:
這次發(fā)現(xiàn)無(wú)論是庫(kù)存訂單都是 OK 的。
查看日志發(fā)現(xiàn):
很多并發(fā)請(qǐng)求會(huì)響應(yīng)錯(cuò)誤,這就達(dá)到了效果。
提高吞吐量
為了進(jìn)一步提高秒殺時(shí)的吞吐量以及響應(yīng)效率,這里的 Web 和 Service 都進(jìn)行了橫向擴(kuò)展:
- Web 利用 Nginx 進(jìn)行負(fù)載。
- Service 也是多臺(tái)應(yīng)用。
再用 JMeter 測(cè)試時(shí)可以直觀的看到效果。
由于我是在阿里云的一臺(tái)小水管服務(wù)器進(jìn)行測(cè)試的,加上配置不高、應(yīng)用都在同一臺(tái),所以并沒(méi)有完全體現(xiàn)出性能上的優(yōu)勢(shì)( Nginx 做負(fù)載轉(zhuǎn)發(fā)時(shí)候也會(huì)增加額外的網(wǎng)絡(luò)消耗)。
Shell 腳本實(shí)現(xiàn)簡(jiǎn)單的 CI
由于應(yīng)用多臺(tái)部署之后,手動(dòng)發(fā)版測(cè)試的痛苦相信經(jīng)歷過(guò)的都有體會(huì)。
這次并沒(méi)有精力去搭建完整的 CICD,只是寫(xiě)了一個(gè)簡(jiǎn)單的腳本實(shí)現(xiàn)了自動(dòng)化部署,希望給這方面沒(méi)有經(jīng)驗(yàn)的同學(xué)帶來(lái)一點(diǎn)啟發(fā)。
構(gòu)建 Web:
- #!/bin/bash
- # 構(gòu)建 web 消費(fèi)者
- #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"
構(gòu)建 Service:
- # 構(gòu)建服務(wù)提供者
- #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"
之后每當(dāng)我有更新,只需要執(zhí)行這兩個(gè)腳本就可以幫我自動(dòng)構(gòu)建。都是最基礎(chǔ)的 Linux 命令,相信大家都看得明白。
樂(lè)觀鎖更新 + 分布式限流
上文的結(jié)果看似沒(méi)有問(wèn)題,其實(shí)還差得遠(yuǎn)呢。這里只是模擬了 300 個(gè)并發(fā)沒(méi)有問(wèn)題,但是當(dāng)請(qǐng)求達(dá)到了 3000,3W,300W 呢?
雖說(shuō)可以橫向擴(kuò)展支撐更多的請(qǐng)求,但是能不能利用最少的資源解決問(wèn)題呢?
仔細(xì)分析下會(huì)發(fā)現(xiàn):假設(shè)我的商品一共只有 10 個(gè)庫(kù)存,那么無(wú)論你多少人來(lái)買其實(shí)最終也最多只有 10 人可以下單成功。所以其中會(huì)有 99% 的請(qǐng)求都是無(wú)效的。
大家都知道:大多數(shù)應(yīng)用數(shù)據(jù)庫(kù)都是壓倒駱駝的***一根稻草。通過(guò) Druid 的監(jiān)控來(lái)看看之前請(qǐng)求數(shù)據(jù)庫(kù)的情況:
因?yàn)?Service 是兩個(gè)應(yīng)用:
數(shù)據(jù)庫(kù)也有 20 多個(gè)連接。怎么樣來(lái)優(yōu)化呢?其實(shí)很容易想到的就是分布式限流。
我們將并發(fā)控制在一個(gè)可控的范圍之內(nèi),然后快速失敗這樣就能***程度的保護(hù)系統(tǒng)。
①distributed-redis-tool ⬆v1.0.3
為此還對(duì) https://github.com/crossoverJie/distributed-redis-tool 進(jìn)行了小小的升級(jí)。
因?yàn)榧由显摻M件之后所有的請(qǐng)求都會(huì)經(jīng)過(guò) Redis,所以對(duì) 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;
- }
- }
這里構(gòu)建器改用了 JedisConnectionFactory,所以得配合 Spring 來(lái)一起使用。
并在初始化時(shí)顯示傳入 Redis 是以集群方式部署還是單機(jī)(強(qiáng)烈建議集群,限流之后對(duì) Redis 還是有一定的壓力)。
③限流實(shí)現(xiàn)
既然 API 更新了,實(shí)現(xiàn)自然也要修改:
- /**
- * 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 應(yīng)用得采用 @SpringControllerLimit(errorCode=200) 注解。
實(shí)際使用如下,Web 端:
- /**
- * 樂(lè)觀鎖更新庫(kù)存 限流
- * @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 端就沒(méi)什么更新了,依然是采用的樂(lè)觀鎖更新數(shù)據(jù)庫(kù)。
再壓測(cè)看下效果 /createOptimisticLimitOrderByRedis/1:
首先是看結(jié)果沒(méi)有問(wèn)題,再看數(shù)據(jù)庫(kù)連接以及并發(fā)請(qǐng)求數(shù)都有明顯的下降。
樂(lè)觀鎖更新+分布式限流+Redis 緩存
仔細(xì)觀察 Druid 監(jiān)控?cái)?shù)據(jù)發(fā)現(xiàn)這個(gè) SQL 被多次查詢:
其實(shí)這是實(shí)時(shí)查詢庫(kù)存的 SQL,主要是為了在每次下單之前判斷是否還有庫(kù)存。
這也是個(gè)優(yōu)化點(diǎn)。這種數(shù)據(jù)我們完全可以放在內(nèi)存中,效率比在數(shù)據(jù)庫(kù)要高很多。
由于我們的應(yīng)用是分布式的,所以堆內(nèi)緩存顯然不合適,Redis 就非常適合。
- 這次主要改造的是 Service 層:
- 每次查詢庫(kù)存時(shí)走 Redis。
- 扣庫(kù)存時(shí)更新 Redis。
需要提前將庫(kù)存信息寫(xiě)入 Redis。(手動(dòng)或者程序自動(dòng)都可以)
主要代碼如下:
- @Override
- public int createOptimisticOrderUseRedis(int sid) throws Exception {
- //檢驗(yàn)庫(kù)存,從 Redis 獲取
- Stock stock = checkStockByRedis(sid);
- //樂(lè)觀鎖更新庫(kù)存 以及更新 Redis
- saleStockOptimisticByRedis(stock);
- //創(chuàng)建訂單
- 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("庫(kù)存不足 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;
- }
- /**
- * 樂(lè)觀鎖更新數(shù)據(jù)庫(kù) 還要更新 Redis
- * @param stock
- */
- private void saleStockOptimisticByRedis(Stock stock) {
- int count = stockService.updateStockByOptimistic(stock);
- if (count == 0){
- throw new RuntimeException("并發(fā)更新庫(kù)存失敗") ;
- }
- //自增
- redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_SALE + stock.getId(),1) ;
- redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_VERSION + stock.getId(),1) ;
- }
壓測(cè)看看實(shí)際效果 /createOptimisticLimitOrderByRedis/1:
***發(fā)現(xiàn)數(shù)據(jù)沒(méi)問(wèn)題,數(shù)據(jù)庫(kù)的請(qǐng)求與并發(fā)也都下來(lái)了。
樂(lè)觀鎖更新+分布式限流+Redis 緩存+Kafka 異步
***的優(yōu)化還是想如何來(lái)再次提高吞吐量以及性能的。我們上文所有例子其實(shí)都是同步請(qǐng)求,完全可以利用同步轉(zhuǎn)異步來(lái)提高性能啊。
這里我們將寫(xiě)訂單以及更新庫(kù)存的操作進(jìn)行異步化,利用 Kafka 來(lái)進(jìn)行解耦和隊(duì)列的作用。
每當(dāng)一個(gè)請(qǐng)求通過(guò)了限流到達(dá)了 Service 層通過(guò)了庫(kù)存校驗(yàn)之后就將訂單信息發(fā)給 Kafka ,這樣一個(gè)請(qǐng)求就可以直接返回了。
消費(fèi)程序再對(duì)數(shù)據(jù)進(jìn)行入庫(kù)落地。因?yàn)楫惒搅耍宰罱K需要采取回調(diào)或者是其他提醒的方式提醒用戶購(gòu)買完成。
這里代碼較多就不貼了,消費(fèi)程序其實(shí)就是把之前的 Service 層的邏輯重寫(xiě)了一遍,不過(guò)采用的是 Spring Boot。
總結(jié)
經(jīng)過(guò)上面的一頓優(yōu)化總結(jié)起來(lái)無(wú)非就是以下幾點(diǎn):
- 盡量將請(qǐng)求攔截在上游。
- 還可以根據(jù) UID 進(jìn)行限流。
- ***程度的減少請(qǐng)求落到 DB。
- 多利用緩存。
- 同步操作異步化。
- Fail Fast,盡早失敗,保護(hù)應(yīng)用。
碼字不易,這應(yīng)該是我寫(xiě)過(guò)字?jǐn)?shù)最多的了,想想當(dāng)年高中 800 字的作文都憋不出來(lái)😂,可想而知是有多難得了。
以上內(nèi)容歡迎討論,感興趣的朋友可以看下:
- https://github.com/crossoverJie/SSM/tree/master/SSM-SECONDS-KILL/SSM-SECONDS-KILL-ORDER-CONSUMER