您好,登錄后才能下訂單哦!
這篇“java怎么實現高性能的秒殺系統”文章的知識點大部分人都不太理解,所以小編給大家總結了以下內容,內容詳細,步驟清晰,具有一定的借鑒價值,希望大家閱讀完這篇文章能有所收獲,下面我們一起來看看這篇“java怎么實現高性能的秒殺系統”文章吧。
首先來看看最終架構圖:
先簡單根據這個圖談下請求的流轉,因為后面不管怎么改進,這些都是不變的:
前端請求進入 Web 層,對應的代碼就是 Controller。
之后將真正的庫存校驗、下單等請求發往 Service 層,其中 RPC 調用依然采用的 Dubbo,只是更新為***版本。
Service 層再對數據進行落地,下單完成。
***制
拋開秒殺這個場景來說,正常的一個下單流程可以簡單分為以下幾步:
校驗庫存
扣庫存
創建訂單
支付
基于上文的架構,我們有了以下實現,先看看實際項目的結構:
還是和以前一樣:
提供出一個 API 用于 Service 層實現,以及 Web 層消費。
Web 層簡單來說就是一個 Spring MVC。
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
由于應用多臺部署之后,手動發版測試的痛苦相信經歷過的都有體會。
這次并沒有精力去搭建完整的 CICD,只是寫了一個簡單的腳本實現了自動化部署,希望給這方面沒有經驗的同學帶來一點啟發。
構建 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
因為加上該組件之后所有的請求都會經過 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 層的邏輯重寫了一遍,不過采用的是 Spring Boot。
以上就是關于“java怎么實現高性能的秒殺系統”這篇文章的內容,相信大家都有了一定的了解,希望小編分享的內容對大家有幫助,若想了解更多相關的知識內容,請關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。