前言
之前在 Java-Interview 中提到過秒殺架構的設計,這次基於其中的理論簡單實現了一下。
本次採用循序漸進的方式逐步提高效能達到併發秒殺的效果,文章較長請準備好瓜子板凳(liushuizhang?)。
本文所有涉及的程式碼:
最終架構圖:
先簡單根據這個圖談下請求的流轉,因為後面不管怎麼改進這個都是沒有變的。
- 前端請求進入
web
層,對應的程式碼就是controller
。 - 之後將真正的庫存校驗、下單等請求發往
Service
層(其中 RPC 呼叫依然採用的dubbo
,只是更新為最新版本,本次不會過多討論 dubbo 相關的細節,有興趣的可以檢視 基於dubbo的分散式架構)。 Service
層再對資料進行落地,下單完成。
無限制
其實拋開秒殺這個場景來說正常的一個下單流程可以簡單分為以下幾步:
- 校驗庫存
- 扣庫存
- 建立訂單
- 支付
基於上文的架構所以我們有了以下實現:
先看看實際專案的結構:
還是和以前一樣:
- 提供出一個
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
為此還對 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);
}
複製程式碼
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。
感興趣的朋友可以看下。
總結
其實經過上面的一頓優化總結起來無非就是以下幾點:
- 儘量將請求攔截在上游。
- 還可以根據 UID 進行限流。
- 最大程度的減少請求落到 DB。
- 多利用快取。
- 同步操作非同步化。
- fail fast,儘早失敗,保護應用。
碼字不易,這應該是我寫過字數最多的了,想想當年高中 800 字的作文都憋不出來?,可想而知是有多難得了。
以上內容歡迎討論。
號外
最近在總結一些 Java 相關的知識點,感興趣的朋友可以一起維護。