SpringBoot微服務(wù)實現(xiàn)秒殺搶購代金券功能
需求分析
現(xiàn)在日常購物或者餐飲消費,商家經(jīng)常會有推出代金券功能,有些時候代金券的數(shù)量不多是需要搶購的,那么怎么設(shè)計可以保證代金券的消耗量和秒殺到的用戶保持一致呢?怎么設(shè)計可以保證一個用戶只能秒殺到一張代金券呢?
秒殺場景的解決方案
秒殺場景有以下幾個特點:
- 大量用戶同時進行搶購操作,系統(tǒng)流量激增,服務(wù)器瞬時壓力很大;
- 請求數(shù)量遠大于商品庫存量,只有少數(shù)客戶可以成功搶購;
- 業(yè)務(wù)流程不復(fù)雜,核心功能是下訂單。
秒殺場景的應(yīng)對,一般要從以下幾個方面進行處理,如下:
限流:從客戶端層面考慮,限制單個客戶搶購頻率;服務(wù)端層面,加強校驗,識別請求是否來源于真實的客戶端,并限制請求頻率,防止惡意刷單;應(yīng)用層面,可以使用漏桶算法或令牌桶算法實現(xiàn)應(yīng)用級限流。緩存:熱點數(shù)據(jù)都從緩存獲得,盡可能減小數(shù)據(jù)庫的訪問壓力;異步:客戶搶購成功后立即返回響應(yīng),之后通過消息隊列,異步處理后續(xù)步驟,如發(fā)短信、更新數(shù)據(jù)庫等,從而緩解服務(wù)器峰值壓力。分流:單臺服務(wù)器肯定無法應(yīng)對搶購期間大量請求造成的壓力,需要集群部署服務(wù)器,通過負載均衡共同處理客戶端請求,分散壓力。
數(shù)據(jù)庫表設(shè)計
本文以搶購代金券為例,來進行數(shù)據(jù)庫表的設(shè)計。
代金券表
CREATE TABLE `t_voucher` ( `id` int(10) NOT NULL AUTO_INCREMENT, `title` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '代金券標(biāo)題', `thumbnail` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '縮略圖', `amount` int(11) NULL DEFAULT NULL COMMENT '抵扣金額', `price` decimal(10, 2) NULL DEFAULT NULL COMMENT '售價', `status` int(10) NULL DEFAULT NULL COMMENT '-1=過期 0=下架 1=上架', `expire_time` datetime(0) NULL DEFAULT NULL COMMENT '過期時間', `redeem_restaurant_id` int(10) NULL DEFAULT NULL COMMENT '驗證餐廳', `stock` int(11) NULL DEFAULT 0 COMMENT '庫存', `stock_left` int(11) NULL DEFAULT 0 COMMENT '剩余數(shù)量', `description` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述信息', `clause` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '使用條款', `create_date` datetime(0) NULL DEFAULT NULL, `update_date` datetime(0) NULL DEFAULT NULL, `is_valid` tinyint(1) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;
搶購活動表
CREATE TABLE `t_seckill_vouchers` ( `id` int(11) NOT NULL AUTO_INCREMENT, `fk_voucher_id` int(11) NULL DEFAULT NULL, `amount` int(11) NULL DEFAULT NULL, `start_time` datetime(0) NULL DEFAULT NULL, `end_time` datetime(0) NULL DEFAULT NULL, `is_valid` int(11) NULL DEFAULT NULL, `create_date` datetime(0) NULL DEFAULT NULL, `update_date` datetime(0) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;
訂單表
CREATE TABLE `t_voucher_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` int(11) NULL DEFAULT NULL, `fk_voucher_id` int(11) NULL DEFAULT NULL, `fk_diner_id` int(11) NULL DEFAULT NULL, `qrcode` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '圖片地址', `payment` tinyint(4) NULL DEFAULT NULL COMMENT '0=微信支付 1=支付寶支付', `status` tinyint(1) NULL DEFAULT NULL COMMENT '訂單狀態(tài):-1=已取消 0=未支付 1=已支付 2=已消費 3=已過期', `fk_seckill_id` int(11) NULL DEFAULT NULL COMMENT '如果是搶購訂單時,搶購訂單的id', `order_type` int(11) NULL DEFAULT NULL COMMENT '訂單類型:0=正常訂單 1=搶購訂單', `create_date` datetime(0) NULL DEFAULT NULL, `update_date` datetime(0) NULL DEFAULT NULL, `is_valid` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;
創(chuàng)建秒殺服務(wù)
pom依賴
引入相關(guān)依賴如下:
<dependencies>
<!-- eureka client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- spring web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- spring data redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons -->
<dependency>
<groupId>com.zjq</groupId>
<artifactId>commons</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.6</version>
</dependency>
</dependencies>
配置文件
server:
port: 7003 # 端口
spring:
application:
name: ms-seckill # 應(yīng)用名
# 數(shù)據(jù)庫
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
url: jdbc:mysql://127.0.0.1:3306/seckill?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
# Redis
redis:
port: 6379
host: localhost
timeout: 3000
password: 123456
# Swagger
swagger:
base-package: com.zjq.seckill
title: 秒殺微服務(wù)API接口文檔
# 配置 Eureka Server 注冊中心
eureka:
instance:
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${server.port}
client:
service-url:
defaultZone: http://localhost:8080/eureka/
mybatis:
configuration:
map-underscore-to-camel-case: true # 開啟駝峰映射
service:
name:
ms-oauth-server: http://ms-oauth2-server/
logging:
pattern:
console: '%d{HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'
關(guān)系型數(shù)據(jù)庫實現(xiàn)代金券秒殺
相關(guān)實體引入
搶購代金券活動信息

代金券訂單信息

Rest配置類
/**
* RestTemplate 配置類
* @author zjq
*/
@Configuration
public class RestTemplateConfiguration {
@LoadBalanced
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN));
restTemplate.getMessageConverters().add(converter);
return restTemplate;
}
}
全局異常處理
/**
*
* 全局異常處理類
* @author zjq
*/
// 將輸出的內(nèi)容寫入 ResponseBody 中
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@Resource
private HttpServletRequest request;
@ExceptionHandler(ParameterException.class)
public ResultInfo<Map<String, String>> handlerParameterException(ParameterException ex) {
String path = request.getRequestURI();
ResultInfo<Map<String, String>> resultInfo =
ResultInfoUtil.buildError(ex.getErrorCode(), ex.getMessage(), path);
return resultInfo;
}
@ExceptionHandler(Exception.class)
public ResultInfo<Map<String, String>> handlerException(Exception ex) {
log.info("未知異常:{}", ex);
String path = request.getRequestURI();
ResultInfo<Map<String, String>> resultInfo =
ResultInfoUtil.buildError(path);
return resultInfo;
}
}
添加代金券秒殺活動
代金券活動實體
上述已引入實體。
代金券活動Mapper->SeckillVouchersMapper
/**
* 秒殺代金券 Mapper
* @author zjq
*/
public interface SeckillVouchersMapper {
/**
* 新增秒殺活動
* @param seckillVouchers 代金券實體
* @return
*/
@Insert("insert into t_seckill_vouchers (fk_voucher_id, amount, start_time, end_time, is_valid, create_date, update_date) " +
" values (#{fkVoucherId}, #{amount}, #{startTime}, #{endTime}, 1, now(), now())")
@Options(useGeneratedKeys = true, keyProperty = "id")
int save(SeckillVouchers seckillVouchers);
/**
* 根據(jù)代金券 ID 查詢該代金券是否參與搶購活動
* @param voucherId 代金券id
* @return
*/
@Select("select id, fk_voucher_id, amount, start_time, end_time, is_valid " +
" from t_seckill_vouchers where fk_voucher_id = #{voucherId}")
SeckillVouchers selectVoucher(Integer voucherId);
}
代金券活動Service->SeckillService
/**
* 秒殺業(yè)務(wù)邏輯層
* @author zjq
*/
@Service
public class SeckillService {
@Resource
private SeckillVouchersMapper seckillVouchersMapper;
/**
* 添加需要搶購的代金券
*
* @param seckillVouchers
*/
@Transactional(rollbackFor = Exception.class)
public void addSeckillVouchers(SeckillVouchers seckillVouchers) {
// 非空校驗
AssertUtil.isTrue(seckillVouchers.getFkVoucherId() == null, "請選擇需要搶購的代金券");
AssertUtil.isTrue(seckillVouchers.getAmount() == 0, "請輸入搶購總數(shù)量");
Date now = new Date();
AssertUtil.isNotNull(seckillVouchers.getStartTime(), "請輸入開始時間");
// 生產(chǎn)環(huán)境下面一行代碼需放行,這里注釋方便測試
// AssertUtil.isTrue(now.after(seckillVouchers.getStartTime()), "開始時間不能早于當(dāng)前時間");
AssertUtil.isNotNull(seckillVouchers.getEndTime(), "請輸入結(jié)束時間");
AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "結(jié)束時間不能早于當(dāng)前時間");
AssertUtil.isTrue(seckillVouchers.getStartTime().after(seckillVouchers.getEndTime()), "開始時間不能晚于結(jié)束時間");
// 驗證數(shù)據(jù)庫中是否已經(jīng)存在該券的秒殺活動
SeckillVouchers seckillVouchersFromDb = seckillVouchersMapper.selectVoucher(seckillVouchers.getFkVoucherId());
AssertUtil.isTrue(seckillVouchersFromDb != null, "該券已經(jīng)擁有了搶購活動");
// 插入數(shù)據(jù)庫
seckillVouchersMapper.save(seckillVouchers);
}
}
驗證數(shù)據(jù)庫表 t_seckill_vouchers 中是否已經(jīng)存在該券的秒殺活動:
- 如果存在則拋出異常;
- 如果不存在則將添加一個代金券搶購活動到 t_seckill_vouchers 表中;
代金券活動Controller->SeckillController

在網(wǎng)關(guān)微服務(wù)中配置秒殺服務(wù)路由和白名單方向
spring:
application:
name: ms-gateway
cloud:
gateway:
discovery:
locator:
enabled: true # 開啟配置注冊中心進行路由功能
lower-case-service-id: true # 將服務(wù)名稱轉(zhuǎn)小寫
routes:
- id: ms-seckill
uri: lb://ms-seckill
predicates:
- Path=/seckill/**
filters:
- StripPrefix=1
secure:
ignore:
urls: # 配置白名單路徑
# 內(nèi)部配置所以放行
- /seckill/add
接口測試
對搶購的代金券下單
SeckillController
/**
* 秒殺下單
*
* @param voucherId 代金券id
* @param access_token 請求token
* @return
*/
@PostMapping("{voucherId}")
public ResultInfo<String> doSeckill(@PathVariable Integer voucherId, String access_token) {
ResultInfo resultInfo = seckillService.doSeckill(voucherId, access_token, request.getServletPath());
return resultInfo;
}
SeckillService
/**
* 搶購代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登錄token
* @Para path 訪問路徑
*/
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {
// 基本參數(shù)校驗
AssertUtil.isTrue(voucherId == null || voucherId < 0, "請選擇需要搶購的代金券");
AssertUtil.isNotEmpty(accessToken, "請登錄");
// 判斷此代金券是否加入搶購
SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);
AssertUtil.isTrue(seckillVouchers == null, "該代金券并未有搶購活動");
// 判斷是否有效
AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "該活動已結(jié)束");
// 判斷是否開始、結(jié)束
Date now = new Date();
AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "該搶購還未開始");
AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "該搶購已結(jié)束");
// 判斷是否賣完
AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "該券已經(jīng)賣完了");
// 獲取登錄用戶信息
String url = oauthServerName + "user/me?access_token={accessToken}";
ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
resultInfo.setPath(path);
return resultInfo;
}
// 這里的data是一個LinkedHashMap,SignInDinerInfo
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
new SignInDinerInfo(), false);
// 判斷登錄用戶是否已搶到(一個用戶針對這次活動只能買一次)
VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
seckillVouchers.getId());
AssertUtil.isTrue(order != null, "該用戶已搶到該代金券,無需再搶");
// 扣庫存
int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());
AssertUtil.isTrue(count == 0, "該券已經(jīng)賣完了");
// 下單
VoucherOrders voucherOrders = new VoucherOrders();
voucherOrders.setFkDinerId(dinerInfo.getId());
voucherOrders.setFkSeckillId(seckillVouchers.getId());
voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());
String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
voucherOrders.setOrderNo(orderNo);
voucherOrders.setOrderType(1);
voucherOrders.setStatus(0);
count = voucherOrdersMapper.save(voucherOrders);
AssertUtil.isTrue(count == 0, "用戶搶購失敗");
return ResultInfoUtil.buildSuccess(path, "搶購成功");
}
代金券訂單 VoucherOrdersMapper
/**
* 代金券訂單 Mapper
* @author zjq
*/
public interface VoucherOrdersMapper {
/**
* 根據(jù)用戶 ID 和秒殺 ID 查詢代金券訂單
* @param userId
* @param voucherId
* @return
*/
@Select("select id, order_no, fk_voucher_id, fk_diner_id, qrcode, payment," +
" status, fk_seckill_id, order_type, create_date, update_date, " +
" is_valid from t_voucher_orders where fk_diner_id = #{userId} " +
" and fk_voucher_id = #{voucherId} and is_valid = 1 and status between 0 and 1 ")
VoucherOrders findDinerOrder(@Param("userId") Integer userId,
@Param("voucherId") Integer voucherId);
/**
* 新增代金券訂單
* @param voucherOrders 代金券實體
* @return
*/
@Insert("insert into t_voucher_orders (order_no, fk_voucher_id, fk_diner_id, " +
" status, fk_seckill_id, order_type, create_date, update_date, is_valid)" +
" values (#{orderNo}, #{fkVoucherId}, #{fkDinerId}, #{status}, #{fkSeckillId}, " +
" #{orderType}, now(), now(), 1)")
int save(VoucherOrders voucherOrders);
}
秒殺代金券活動 SeckillVouchersMapper
/**
* 減庫存
* @param seckillId 秒殺id
* @return
*/
@Update("update t_seckill_vouchers set amount = amount - 1 " +
" where id = #{seckillId}")
int stockDecrease(@Param("seckillId") int seckillId);
測試驗證
壓力測試
下載安裝JMeter
JMeter安裝和使用可以參考我這篇文章:JMeter安裝和使用
初始化2000個用戶數(shù)據(jù)

數(shù)據(jù)庫新增2000個用戶數(shù)據(jù),賬號為test0到test1999,密碼統(tǒng)一設(shè)置為123456。
認證微服務(wù)生產(chǎn)2000個token
初始化2000個token信息,存儲在token.txt文件中。
代碼如下:
@Test
public void writeToken() throws Exception {
String authorization = Base64Utils.encodeToString("appId:123456".getBytes());
StringBuffer tokens = new StringBuffer();
for (int i = 0; i < 2000; i++) {
MvcResult mvcResult = super.mockMvc.perform(MockMvcRequestBuilders.post("/oauth/token")
.header("Authorization", "Basic " + authorization)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "test" + i)
.param("password", "123456")
.param("grant_type", "password")
.param("scope", "api")
)
.andExpect(status().isOk())
// .andDo(print())
.andReturn();
String contentAsString = mvcResult.getResponse().getContentAsString();
ResultInfo resultInfo = (ResultInfo) JSONUtil.toBean(contentAsString, ResultInfo.class);
JSONObject result = (JSONObject) resultInfo.getData();
String token = result.getStr("accessToken");
tokens.append(token).append("\r\n");
}
Files.write(Paths.get("tokens.txt"), tokens.toString().getBytes());
}

測試多人搶購代金券
添加一個代金券搶購活動信息:

通過jmeter添加用戶測試計劃,3000個線程同時發(fā)起兩千個用戶執(zhí)行測試:


測試后結(jié)果如下:

可以看到有些請求是失敗的,因為沒有做優(yōu)化,抗不了這么大的并發(fā)。然后查看數(shù)據(jù)庫情況發(fā)現(xiàn)庫存已經(jīng)超賣,100個庫存,賣了230單,庫存成了負數(shù)??????。


測試同一用戶搶購多次代金券
重置數(shù)據(jù)庫數(shù)據(jù)后,測試同一個用戶,1000個線程發(fā)起并發(fā)請求。


查看數(shù)據(jù)庫發(fā)現(xiàn)這一個用戶就下了10單。。。

很明顯出現(xiàn)了超賣和同一個用戶可以多次搶購?fù)淮鹑膯栴},再后續(xù)博客中我會提供基于Redis來解決超賣和同一用戶多次搶購的問題。
到此這篇關(guān)于SpringBoot微服務(wù)實現(xiàn)秒殺搶購代金券功能的文章就介紹到這了,更多相關(guān)SpringBoot 搶購代金券 內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java StringBuilder和StringBuffer源碼分析
這篇文章主要針對Java中兩個常用的操作字符串的類 StringBuilder和StringBuffer進行源碼分析,感興趣的小伙伴們可以參考一下2016-01-01
關(guān)于Logback+MyBatis日志輸出問題的一些思考
這篇文章主要介紹了關(guān)于Logback+MyBatis日志輸出問題的一些思考,具有很好的參考價值,希望對大家有所幫助,2023-09-09
shiro與spring集成基礎(chǔ)Hello案例詳解
這篇文章主要介紹了shiro與spring集成基礎(chǔ)Hello案例詳解,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-11-11
Java實現(xiàn)Excel轉(zhuǎn)化為圖片的代碼詳解
在實際開發(fā)里,常常存在將Excel表格或特定區(qū)域轉(zhuǎn)換為圖片,以便在其他軟件中使用的需求,在Java開發(fā)中,借助報表插件能夠輕松實現(xiàn)將工作表、任意指定區(qū)域以及各種形狀類型轉(zhuǎn)換為圖片,本文將詳細介紹如何使用Java達成Excel到圖片的轉(zhuǎn)化,需要的朋友可以參考下2025-08-08
SpringBoot2 Jpa 批量刪除功能的實現(xiàn)
這篇文章主要介紹了SpringBoot2 Jpa 批量刪除功能的實現(xiàn),具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-01-01

