單機秒殺系統的架構設計與實現
一,秒殺系統
1,秒殺場景
電商搶購限量商品
搶購演唱會的門票
火車票搶座12306
…
2.為什麼要做個系統
如果專案流量非常小,完全不用擔心併發請求的購買,那麼做這樣一個系統的意義並不大。但是如果你的系統要是像12306一樣,接受高併發訪問和下單的考驗,那麼你就需要一套完整的流程保護措施,來保證你係統在使用者流量高峰期不會被搞掛了。
嚴格防止超賣:庫存一百件,賣出去120件。
防止黑產:一個人全買了,其他人啥也沒有。
保證使用者體驗:高併發下,網頁打不開,支付不成功,購物車進不去,地址改不了,這個問題非常之大,涉及到各種技術。
3.保護措施有哪些
樂觀鎖防止超賣
令牌桶限流
redis快取
訊息佇列非同步處理訂單
…
二,無鎖狀態下的秒殺系統
1.業務流程分析
1.前端接受一個秒殺請求傳遞到後端控制器
2.控制器接受請求引數,呼叫業務建立訂單
3.業務層需要檢驗庫存,扣除庫存,(判斷使用者是否重複購買),建立訂單
2.搭建專案
sql指令碼
CREATE TABLE `ms_order` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '訂單表',
`product_id` int(11) DEFAULT NULL COMMENT '商品id',
`create_time` datetime DEFAULT NULL COMMENT '建立時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
CREATE TABLE `ms_stock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '庫存表',
`product_id` int(11) DEFAULT NULL COMMENT '商品id',
`product_name` varchar(255) DEFAULT NULL COMMENT '商品名稱',
`sum` int(11) DEFAULT NULL COMMENT '商品數量',
`sale` int(11) DEFAULT NULL COMMENT '售出數量',
`version` int(11) DEFAULT '0' COMMENT '版本號',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
pom依賴
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yhd</groupId>
<artifactId>ms</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ms</name>
<description>ms project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>1.18.8</version>
</dependency>
</dependencies>
實體類
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_order")
//開啟鏈式呼叫
@Accessors(fluent = true)
@Data
public class Order implements Serializable {
@TableId(type = IdType.AUTO)
private Integer id;
private Integer productId;
private Date createTime;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_stock")
//開啟鏈式呼叫
@Accessors(fluent = true)
public class Stock implements Serializable {
private Integer id;
private Integer productId;
private String productName;
private Integer sum;
private Integer sale;
private Integer version;
}
mapper
public interface OrderMapper extends BaseMapper<Order> {
}
public interface StockMapper extends BaseMapper<Stock> {
}
service
@Service
public class OrderService {
@Resource
private OrderMapper orderMapper;
@Resource
private StockMapper stockMapper;
/**
* 1.驗證庫存
* 2.修改庫存
* 3.建立訂單
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId) {
Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
if (product.sum().equals(product.sale())){
throw new RuntimeException("搶購失敗,商品已經賣光!");
}else{
stockMapper.updateById(product.sale(product.sale()+1));
Order order = new Order();
orderMapper.insert(order.createTime(new Date()).productId(productId));
return order;
}
}
}
controller
@RestController
@RequestMapping("order")
public class OrderController {
@Resource
private OrderService orderService;
/**
* 使用者點選搶購,開始下單
*/
@GetMapping("qg/{productId}")
public Order Qg(@PathVariable("productId") Integer productId){
return orderService.Qg(productId);
}
}
配置檔案
server.port=8888
server.servlet.context-path=/ms
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///ms?characterEncoding=utf-8&useSSL=false&serverTimezone=GMT%2B8
mybatis-plus.type-aliases-package=com.yhd.ms.domain
mybatis-plus.mapper-locations=classpath:mapper/*.xml
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
3.jmeter壓測工具
沒有併發的情況下能夠正常生成訂單,但是當產生併發請求的時候,就會發生超賣問題。
三,單機下使用悲觀鎖解決超賣問題
首先因為synchronized
是本地鎖,如果是叢集模式下,這樣加鎖是無法解決超賣的。
1.synchronized和事務的小問題
@Transactional
public synchronized Order Qg(Integer productId) {
Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
if (product.sum().equals(product.sale())){
throw new RuntimeException("搶購失敗,商品已經賣光!");
}else{
stockMapper.updateById(product.sale(product.sale()+1));
Order order = new Order();
orderMapper.insert(order.createTime(new Date()).productId(productId));
return order;
}
}
單機模式下,我們在業務層程式碼加上synchronized
關鍵字,加上以後發現並沒有解決超賣問題,原因是synchronized這把鎖是在事務裡面的一部分,釋放鎖以後,實際上事務並未執行完,當事務提交,還是會修改資料庫,相當於鎖白加了。
2.解決方案
第一種方法就是吧事務去掉,但是業務層程式碼不加事務的問題就不用多描述了。所以採用第二種
第二種:
/**
* 使用者點選搶購,開始下單
*/
@GetMapping("qg/{productId}")
public Order Qg(@PathVariable("productId") Integer productId){
synchronized (this) {
return orderService.Qg(productId);
}
}
/**
* 1.驗證庫存
* 2.修改庫存
* 3.建立訂單
*
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId) {
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
return order;
}
/**
* 驗證庫存
*
* @param productId
* @return
*/
private Stock checkStock(Integer productId) {
Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
if (product.sum().equals(product.sale())) {
throw new RuntimeException("搶購失敗,商品已經賣光!");
}
return product;
}
/**
* 更新庫存
*
* @param stock
* @return
*/
private Integer updateStock(Stock stock) {
return stockMapper.updateById(stock.sale(stock.sale() + 1));
}
/**
* 建立訂單
*
* @param stock
* @return
*/
private Order createOrder(Stock stock) {
Order order = new Order();
orderMapper.insert(order.createTime(new Date()).productId(stock.productId()));
return order;
}
這次成功的解決了超賣問題,但是同時悲觀鎖也帶來了效率低下的問題。
四,單機下使用樂觀鎖解決超賣問題
使用樂觀搜解決商品超賣問題,實際上是把主要防止超賣問題交給資料庫解決,利用資料庫中定義的version
欄位以及資料庫中的事務實現在併發情況下商品超賣問題。
select * from ms_stock where id =1 and version =0;
update ms_stock set sale=sale+1,version=version+1 where id=#{id} and version =#{version}
經過壓力測試,發現不但解決了超賣問題,效率上也得到了很大的提高,但是當請求數量在一秒鐘上升到20000個的時候,可以看到,系統崩潰了。
五,令牌桶介面限流防止系統崩潰
1.介面限流
限流:對某一時間視窗內的請求進行限制,保持系統的可用性和穩定性,防止因為流量暴增而導致的系統執行緩慢或者當機。
在面臨高併發的搶購請求時,我們如果不對介面進行限流,可能會對後臺系統造成極大壓力。大量的請求搶購成功時需要呼叫下單介面,過多的請求打到資料庫會對系統的穩定性造成影響。
2.如何解決介面限流
常用的限流演算法有令牌桶演算法和漏桶演算法,而谷歌的開源專案Guava
中的RateLimiter
使用的就是令牌桶控制演算法。在開發高併發系統時有三把利器保護系統:快取,降級和限流。
快取:快取的目的是提升系統訪問速度和增大系統的處理容量。
降級:降級是當伺服器壓力劇增的情況下,根據當前業務情況及流量對一些服務和頁面有策略的降級,以此釋放伺服器資源以保證核心任務的正常執行。
限流:通過對併發訪問/請求進行限速,或者對一個時間視窗內的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務,排隊或者等待,降級等處理。
3.漏桶演算法和令牌桶演算法
漏桶演算法:請求先進入到漏桶裡,漏桶以一定的速度出水,當水流入速度過大會直接溢位,可以看出漏桶演算法能強行限制資料的傳輸速率。
令牌桶演算法:在網路傳輸資料時,為了防止網路阻塞,需要限制流出網路的流量,使流量以比較均勻的速度向外傳送。令牌桶演算法就實現了這個功能,可控制傳送到網路上資料的數目,並允許突發資料的傳送。大小固定的令牌桶可自行以恆定的速率源源不斷的產生令牌。如果令牌不被消耗,或者被消耗的速度小於產生的速度,令牌就會不斷的增多,直到把通填滿。後面在產生的令牌就會從桶中溢位。最後桶中可以儲存的最大令牌數永遠不會超過桶的大小。這意味著,面對瞬時大流量,該演算法可以在短時間內請求拿到大量令牌,而且那令牌的過程並不是消耗很大的事情。
4.令牌桶使用案例
/**
* 建立令牌桶
*/
private RateLimiter rateLimiter=RateLimiter.create(10);
/**
* 測試令牌桶演算法
*
* @return
*/
@GetMapping("test")
public String testLpt(){
if (rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
log.info("爭搶令牌消耗的時間為:" +rateLimiter.acquire());
//模擬處理業務邏輯耗時
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "成功搶到令牌,消耗時間為:+" +rateLimiter.acquire();
}
log.info("未搶到令牌,無法執行業務邏輯!");
return "未搶到令牌,無法執行業務邏輯!";
}
5.使用令牌桶優化秒殺系統
private RateLimiter rateLimiter=RateLimiter.create(10);
/**
* 使用者點選搶購,開始下單
*/
@GetMapping("qg/{productId}")
public String Qg(@PathVariable("productId") Integer productId) {
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
return "搶購失敗,請重試!";
}
orderService.Qg(productId);
return "搶購成功,耗時為:"+rateLimiter.acquire();
}
六,隱藏秒殺介面
解決了超賣和限流問題,還要關注一些細節,此時秒殺系統還存在一些問題:
1.我們應該在一定的時間內執行秒殺處理,不能在任意時間都接受秒殺請求。如何加入時間驗證?
2.對於稍微懂電腦的人,又會通過抓包的方式獲取我們的介面地址,我們通過指令碼進行搶購怎們麼辦?
3.秒殺開始之後如何限制單個使用者的請求頻率,即單位時間內限制訪問次數?
1.使用redis實現限時搶購
@Resource
private StringRedisTemplate redisTemplate;
@Transactional
public Order Qg(Integer productId) {
checkTime(productId);
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
return order;
}
/**
* 使用redis實現限時搶購
*/
public void checkTime(Integer productId){
Boolean flag = redisTemplate.hasKey("SECOND_KILL" + productId);
if (!flag){
throw new RuntimeException("秒殺活動已經結束,歡迎下次再來!");
}
}
2.秒殺介面的隱藏處理
我們需要將秒殺介面進行隱藏的具體方法:
每次點選秒殺按鈕,實際上是兩次請求,第一次先從伺服器獲取一個秒殺驗證值(介面內判斷是否到秒殺時間)
redis以快取使用者ID和商品ID為key,秒殺地址為value快取驗證值
使用者請求秒殺商品的時候,要帶上秒殺驗證值進行校驗
加入使用者表
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_user")
//開啟鏈式呼叫
@Accessors(fluent = true)
public class User implements Serializable {
private Integer id;
private String name;
private String pwd;
}
生成MD5介面
/**
* 生成MD5介面
*/
@GetMapping("md5/{pid}/{uid}")
public String createMD5(@PathVariable("pid")Integer pid,@PathVariable("uid")Integer uid){
return orderService.createMD5(pid,uid);
}
/**
* 根據使用者id和商品id生成隨機鹽
* 1.檢驗使用者合法性
* 2.校驗庫存
* 3.生成hashkey
* 4.生成MD5
*
* @param pid
* @param uid
* @return
*/
@Transactional
public String createMD5(Integer pid, Integer uid) {
checkUser(uid);
checkStock(pid);
return createKey(pid, uid);
}
/**
* 校驗使用者合法性
*/
public void checkUser(Integer id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new RuntimeException("使用者不存在!");
}
}
/**
* 生成key,並存入redis
*
* @param pid
* @param uid
* @return
*/
public String createKey(Integer pid, Integer uid) {
String key = "SECOND_KILL" + pid + uid;
String value = MD5Encoder.encode(key.getBytes());
redisTemplate.opsForValue().set(key, value, 60, TimeUnit.SECONDS);
return value;
}
修改下單介面
/**
* 0.檢驗MD5
* 1.驗證庫存
* 2.修改庫存
* 3.建立訂單
*
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId, Integer uid, String md5) {
checkMD5(productId, uid, md5);
checkTime(productId);
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
return order;
}
/**
* 生成訂單前校驗MD5
*
* @param uid
* @param md5
*/
private void checkMD5(Integer pid, Integer uid, String md5) {
if (!md5.equals(createMD5(pid, uid))) {
throw new RuntimeException("引數非法!");
}
}
3.單使用者介面呼叫頻率限制
為了防止出現使用者擼羊毛,限制使用者的購買數量。
用redis給每個使用者做訪問統計,甚至帶上商品id,對單個商品進行訪問統計。
/**
* 使用者點選搶購,開始下單
*/
@GetMapping("qg/{productId}/{uid}/{md5}")
public String Qg(@PathVariable("productId") Integer productId,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5) {
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
return "搶購失敗,請重試!";
}
//查詢使用者是否搶購過該商品 ,並在使用者下單成功後將該商品加入redis
if (!userService.checkIsBuy(uid,productId)){
return "已經購買過該商品,請勿重複下單!";
}
orderService.Qg(productId,uid,md5);
return "搶購成功,耗時為:"+rateLimiter.acquire();
}
/**
* 校驗使用者是否購買過該商品
* @param uid
* @param productId
* @return
*/
public boolean checkIsBuy(Integer uid, Integer productId) {
return redisTemplate.opsForHash().hasKey("SECOND_KILL_BUYED"+productId,uid);
}
/**
* 0.檢驗MD5
* 1.驗證庫存
* 2.修改庫存
* 3.建立訂單
* 4.使用者下單成功後將該商品加入redis
*
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId, Integer uid, String md5) {
checkMD5(productId, uid, md5);
checkTime(productId);
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
updateRedis(uid, productId);
return order;
}
/**
* 使用者下單成功後將該商品加入redis
*/
private void updateRedis(Integer uid, Integer productId) {
redisTemplate.opsForHash().increment("SECOND_KILL_BUYED"+productId,uid, 1);
}
至此,單機的小體量秒殺系統基本結束,為什麼說小體量?因為現在我們的合法購買請求完全打入到了資料庫,對資料庫壓力過大,我們可以考慮操作redis快取,使用訊息佇列實現非同步下單支付。同時,如果體量再次升級,我們可以考慮使用叢集,分散式,隨之而來就產生了新的問題,分散式鎖的解決。
相關文章
- 秒殺系統架構分析與實戰架構
- 分散式抽獎秒殺系統,DDD架構設計和實現分享分散式架構
- Redis秒殺系統架構設計-微信搶紅包Redis架構
- 秒殺系統架構如何設計之我見架構
- 秒殺架構模型設計架構模型
- 秒殺系統的設計
- 電商網站秒殺與搶購的系統架構網站架構
- 秒殺系統設計
- [系統設計]秒殺系統
- 架構 秒殺系統優化思路架構優化
- 秒殺系統架構優化思路架構優化
- 億級流量架構實戰之秒殺設計架構
- 秒殺架構實踐架構
- 高併發秒殺系統架構詳解,不是所有的秒殺都是秒殺!架構
- 電商秒殺系統設計
- 手撕商城系統架構設計與實現架構
- 【高併發】秒殺系統架構解密,不是所有的秒殺都是秒殺(升級版)!!架構解密
- 這是我讀過寫得最好的【秒殺系統架構】分析與實戰!架構
- Redis輕鬆實現秒殺系統Redis
- 如何設計一個秒殺系統
- 面試必考:秒殺系統如何設計?面試
- 如何設計一個秒殺系統?
- 阿里P8級架構師淺析秒殺架構設計實踐思路阿里架構
- 秒殺系統設計中的業務性思考
- 秒殺系統設計的5個要點
- 用Redis輕鬆實現秒殺系統Redis
- 用 Redis 輕鬆實現秒殺系統Redis
- 《吊打面試官》系列-秒殺系統設計面試
- 怎麼設計一個秒殺系統
- 阿里的秒殺系統是怎麼設計的?阿里
- 如何設計一個優秀的秒殺系統?
- 基於雲原生的秒殺系統設計思路
- 經驗:一個秒殺系統的設計思考
- 秒殺系統設計中的資料處理
- python版:單機redis實現秒殺,防止超限PythonRedis
- PHP秒殺系統全方位設計分析(二)PHP
- PHP秒殺系統全方位設計分析(一)PHP
- 基於SSH培訓機構管理系統的設計與實現