购物车与结算 / 秒杀系统
1. 购物车系统
1.1 购物车数据模型
购物车核心实体包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| cart_id | bigint | 购物车记录主键 ID |
| user_id | bigint | 用户 ID(未登录时为 0) |
| sku_id | bigint | 商品 SKU ID |
| quantity | int | 加入数量 |
| checked | tinyint | 是否勾选(1: 勾选, 0: 未勾选) |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
sql
CREATE TABLE cart (
cart_id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL DEFAULT 0,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
checked TINYINT NOT NULL DEFAULT 1 COMMENT '1-勾选 0-未勾选',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
UNIQUE KEY uk_user_sku (user_id, sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;未登录状态下,购物车数据存储在本地浏览器(LocalStorage 或 Cookie)中:
json
[
{ "skuId": 1001, "quantity": 2, "checked": true, "addTime": 1712345678 },
{ "skuId": 1002, "quantity": 1, "checked": true, "addTime": 1712345680 }
]1.2 购物车合并
用户未登录时数据在本地,登录后需要将本地购物车与服务端购物车合并。合并策略如下:
- 本地有、服务端无:直接插入记录。
- 本地和服务端都有相同 SKU:数量相加(上限为商品库存量)。
- 本地无、服务端有:保留服务端数据。
- 合并完成后清除本地购物车。
java
public void mergeCart(Long userId, List<LocalCartItem> localCart) {
List<Cart> dbCartList = cartMapper.selectByUserId(userId);
Map<Long, Cart> dbCartMap = dbCartList.stream()
.collect(Collectors.toMap(Cart::getSkuId, Function.identity()));
for (LocalCartItem item : localCart) {
Cart dbCart = dbCartMap.get(item.getSkuId());
if (dbCart == null) {
// 本地有、服务端无:新增
Cart insert = new Cart();
insert.setUserId(userId);
insert.setSkuId(item.getSkuId());
insert.setQuantity(item.getQuantity());
insert.setChecked(true);
cartMapper.insert(insert);
} else {
// 本地和服务端都有:数量相加,不超过库存
int maxStock = stockService.getStock(item.getSkuId());
int merged = Math.min(dbCart.getQuantity() + item.getQuantity(), maxStock);
dbCart.setQuantity(merged);
dbCart.setChecked(true);
cartMapper.updateById(dbCart);
}
}
// 清除本地购物车(客户端调用)
}1.3 购物车持久化与缓存
登录后购物车数据采用 Redis Hash 结构缓存,配合 MySQL 持久化:
Redis 数据结构:
Key: cart:user:{userId}
Field: {skuId} -> Value: {"quantity":2,"checked":1}过期策略:
- 设置 TTL 为 7 天,每次操作刷新过期时间。
- 通过 Redis 的
expire命令实现滑动过期。 - 使用异步任务定期将 Redis 中的购物车数据回写 MySQL。
java
public void addToCart(Long userId, Long skuId, Integer quantity) {
String key = "cart:user:" + userId;
String field = String.valueOf(skuId);
// 使用 Hash 结构存储
Map<String, String> item = new HashMap<>();
item.put("quantity", quantity.toString());
item.put("checked", "1");
redisTemplate.opsForHash().putAll(key + ":" + field, item);
// 设置过期时间
redisTemplate.expire(key, 7, TimeUnit.DAYS);
}1.4 购物车操作
数量变更:
java
public void updateQuantity(Long userId, Long skuId, Integer quantity) {
String key = "cart:user:" + userId;
String field = String.valueOf(skuId);
redisTemplate.opsForHash().put(key, field + ":quantity", quantity.toString());
// 同时更新 MySQL
cartMapper.updateQuantity(userId, skuId, quantity);
}勾选 / 取消勾选:
java
public void checkItem(Long userId, Long skuId, boolean checked) {
String key = "cart:user:" + userId;
String field = String.valueOf(skuId);
redisTemplate.opsForHash().put(key, field + ":checked", checked ? "1" : "0");
cartMapper.updateChecked(userId, skuId, checked);
}下单后清理已购买商品:
java
public void clearCheckedItems(Long userId, List<Long> skuIdList) {
String key = "cart:user:" + userId;
skuIdList.forEach(skuId -> {
redisTemplate.opsForHash().delete(key, String.valueOf(skuId));
});
cartMapper.deleteByUserIdAndSkuIds(userId, skuIdList);
}1.5 购物车过期商品提醒
在查询购物车时,异步检查商品状态并返回提醒信息:
java
public CartCheckResult checkCartItems(Long userId) {
List<Cart> cartList = getCartList(userId);
List<Long> skuIds = cartList.stream().map(Cart::getSkuId).collect(Collectors.toList());
Map<Long, ProductInfo> productMap = productService.batchQuery(skuIds);
CartCheckResult result = new CartCheckResult();
for (Cart cart : cartList) {
ProductInfo product = productMap.get(cart.getSkuId());
if (product == null || product.getStatus() != 1) {
result.addExpired(cart.getSkuId(), "商品已下架");
} else if (product.getStock() <= 0) {
result.addExpired(cart.getSkuId(), "商品已售罄");
} else if (cart.getQuantity() > product.getStock()) {
result.addWarning(cart.getSkuId(), "商品库存不足,当前库存 " + product.getStock());
}
}
return result;
}2. 结算系统
2.1 结算页信息聚合
结算页需要聚合以下信息模块:
| 模块 | 数据来源 | 内容 |
|---|---|---|
| 收货地址 | 地址服务 | 默认地址、地址列表、地址新增/编辑 |
| 商品清单 | 购物车服务 | 勾选商品列表、SKU 信息、数量 |
| 优惠券 | 营销服务 | 可用优惠券、不可用优惠券、最优推荐 |
| 运费 | 运费服务 | 根据地址和商品计算运费 |
| 订单摘要 | 订单服务 | 商品总额、运费、优惠减免、应付总额 |
java
public SettlementVO getSettlement(Long userId) {
// 并行查询各模块数据
CompletableFuture<List<AddressVO>> addressFuture =
CompletableFuture.supplyAsync(() -> addressService.getUserAddresses(userId));
CompletableFuture<List<CartVO>> cartFuture =
CompletableFuture.supplyAsync(() -> cartService.getCheckedItems(userId));
CompletableFuture<List<CouponVO>> couponFuture =
CompletableFuture.supplyAsync(() -> couponService.getAvailableCoupons(userId));
CompletableFuture.allOf(addressFuture, cartFuture, couponFuture).join();
List<CartVO> cartList = cartFuture.join();
List<AddressVO> addressList = addressFuture.join();
List<CouponVO> couponList = couponFuture.join();
// 计算运费
Long addressId = addressList.stream().filter(AddressVO::getIsDefault)
.findFirst().map(AddressVO::getId).orElse(null);
FreightResult freight = freightService.calculate(addressId, cartList);
// 计算优惠
CouponResult bestCoupon = couponEngine.calculateBest(couponList, cartList);
SettlementVO vo = new SettlementVO();
vo.setAddressList(addressList);
vo.setCartList(cartList);
vo.setCouponList(couponList);
vo.setFreight(freight);
vo.setTotalAmount(calculateTotal(cartList));
vo.setDiscountAmount(bestCoupon.getDiscountAmount());
vo.setFreightAmount(freight.getAmount());
vo.setPayAmount(vo.getTotalAmount() - vo.getDiscountAmount() + vo.getFreightAmount());
return vo;
}2.2 运费计算策略
运费计算采用策略模式,支持多种计费规则:
java
public interface FreightStrategy {
BigDecimal calculate(Address address, List<CartVO> items);
}
// 按件计费
@Component
public class PerPieceFreightStrategy implements FreightStrategy {
@Override
public BigDecimal calculate(Address address, List<CartVO> items) {
int totalPieces = items.stream().mapToInt(CartVO::getQuantity).sum();
// 首件 10 元,续件每件 5 元
if (totalPieces <= 1) return new BigDecimal("10.00");
return new BigDecimal("10.00").add(
new BigDecimal(totalPieces - 1).multiply(new BigDecimal("5.00")));
}
}
// 按重量计费
@Component
public class WeightBasedFreightStrategy implements FreightStrategy {
@Override
public BigDecimal calculate(Address address, List<CartVO> items) {
double totalWeight = items.stream()
.mapToDouble(item -> item.getWeight() * item.getQuantity()).sum();
// 首重 1kg 8 元,续重每 kg 4 元
if (totalWeight <= 1.0) return new BigDecimal("8.00");
double extra = Math.ceil(totalWeight - 1.0);
return new BigDecimal("8.00").add(
BigDecimal.valueOf(extra).multiply(new BigDecimal("4.00")));
}
}
// 包邮模板
@Component
public class FreeShippingFreightStrategy implements FreightStrategy {
@Override
public BigDecimal calculate(Address address, List<CartVO> items) {
return BigDecimal.ZERO;
}
}运费根据商品所属店铺的运费模板决定使用哪种策略,支持区域差异化配置:
sql
CREATE TABLE freight_template (
template_id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
shop_id BIGINT NOT NULL,
strategy_type TINYINT NOT NULL COMMENT '1-按件 2-按重量 3-包邮',
condition_amount DECIMAL(10,2) DEFAULT NULL COMMENT '满额包邮条件',
region_codes VARCHAR(500) DEFAULT NULL COMMENT '适用地区编码,逗号分隔',
first_unit DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '首件/首重',
first_fee DECIMAL(10,2) NOT NULL COMMENT '首费',
extra_unit DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '续件/续重单位',
extra_fee DECIMAL(10,2) NOT NULL COMMENT '续费'
);2.3 优惠券引擎
优惠券类型及规则:
| 类型 | 规则 | 示例 |
|---|---|---|
| 满减券 | 满 X 元减 Y 元 | 满 200 减 30 |
| 折扣券 | 满 X 元打 Y 折 | 满 100 打 8 折 |
| 立减券 | 直接减 Y 元(无门槛) | 立减 10 元 |
互斥与叠加规则:
- 同一商品 SKU 只能使用一张优惠券(互斥)。
- 不同店铺的优惠券可以叠加(跨店场景)。
- 平台券和店铺券可以叠加。
- 立减券与满减/折扣券不可叠加。
- 优惠金额不能超过订单金额。
java
public class CouponEngine {
public CouponResult calculateBest(List<CouponVO> coupons, List<CartVO> items) {
BigDecimal total = calculateTotal(items);
// 筛选可用优惠券
List<CouponVO> usable = coupons.stream()
.filter(c -> c.getThreshold().compareTo(total) <= 0)
.sorted(Comparator.comparing(CouponVO::getDiscount).reversed())
.collect(Collectors.toList());
if (usable.isEmpty()) {
return CouponResult.empty();
}
// 最优推荐:选择优惠金额最大的一张券(非叠加场景)
CouponVO best = usable.get(0);
BigDecimal discount = calculateDiscount(best, total);
return CouponResult.builder()
.couponId(best.getCouponId())
.couponName(best.getName())
.discountAmount(discount)
.build();
}
private BigDecimal calculateDiscount(CouponVO coupon, BigDecimal total) {
switch (coupon.getType()) {
case "FULL_REDUCTION":
return coupon.getDiscount().min(total);
case "PERCENTAGE":
BigDecimal discounted = total.multiply(
coupon.getDiscount().divide(new BigDecimal("10"), 2, RoundingMode.HALF_UP));
return total.subtract(discounted).setScale(2, RoundingMode.HALF_UP);
case "DIRECT":
return coupon.getDiscount().min(total);
default:
return BigDecimal.ZERO;
}
}
}2.4 凑单推荐逻辑
当用户可用优惠券有门槛但当前金额不足时,系统自动推荐凑单商品:
java
public List<RecommendVO> getTopUpRecommend(List<CartVO> cartItems, CouponVO coupon) {
BigDecimal currentTotal = calculateTotal(cartItems);
BigDecimal gap = coupon.getThreshold().subtract(currentTotal);
if (gap.compareTo(BigDecimal.ZERO) <= 0) {
return Collections.emptyList();
}
// 获取当前购物车中已有的商品分类,推荐同店同品类商品
List<Long> shopIds = cartItems.stream().map(CartVO::getShopId).distinct().collect(Collectors.toList());
List<Long> categoryIds = cartItems.stream().map(CartVO::getCategoryId).distinct().collect(Collectors.toList());
// 查询价格在 gap 范围内的商品,按价格升序推荐
List<ProductVO> products = productService.recommendByPriceRange(
shopIds, categoryIds, gap, new BigDecimal("50"));
return products.stream()
.map(p -> RecommendVO.builder()
.skuId(p.getSkuId())
.name(p.getName())
.price(p.getPrice())
.suggestQuantity(calculateSuggestQty(p.getPrice(), gap))
.build())
.collect(Collectors.toList());
}3. 秒杀系统整体架构
3.1 分层限流架构
秒杀系统采用多层防护架构,从用户请求到数据库写入逐层过滤:
用户请求
|
v
[CDN 静态化] -- 商品详情页、秒杀页静态资源缓存至 CDN,动态数据通过接口异步加载
|
v
[前端限流] -- 点击按钮置灰(1 秒内只能点击一次)、倒计时防提前抢跑
|
v
[Nginx 限流] -- ngx_http_limit_req_module 按 IP / UserID 限流
|
v
[应用层限流] -- 分布式限流(Redis + Lua 令牌桶 / 滑动窗口)
|
v
[Redis 预减库存] -- Redis Lua 脚本原子扣减库存,判断是否可继续
|
v
[RocketMQ 异步下单] -- 扣减成功后发送消息,异步创建订单
|
v
[数据库最终一致性] -- 订单落库,扣减 DB 库存,保证最终一致性3.2 各层职责
| 层级 | 技术方案 | 目标 QPS | 作用 |
|---|---|---|---|
| CDN | 全站加速 + 静态资源缓存 | 百万级 | 抗住绝大多数静态请求 |
| 前端限流 | JS 点击置灰 + 倒计时同步 | - | 防止用户频繁点击 |
| Nginx 限流 | limit_req_zone + limit_conn_zone | 单机 10 万 | 拦截恶意 IP 和低水平 DDoS |
| 应用层限流 | Redis 令牌桶 / 滑动窗口 | 单机 1 万 | 按用户 ID 细粒度限流 |
| Redis 预减 | Lua 脚本原子操作 | 单机 5 万 | 真正承载秒杀流量峰值 |
| RocketMQ | 异步可靠消息 | 削峰填谷 | 将瞬时流量转为有序的异步处理 |
| MySQL | 读写分离 + 分库分表 | 单库 5000 | 订单最终落库 |
4. 秒杀核心实现
4.1 Redis 库存预热
秒杀开始前,将商品库存从数据库加载到 Redis:
java
public void preheatStock(Long seckillId, Integer totalStock) {
String stockKey = "seckill:stock:" + seckillId;
redisTemplate.opsForValue().set(stockKey, String.valueOf(totalStock));
// 设置秒杀结束时间后自动过期,释放内存
redisTemplate.expire(stockKey, 2, TimeUnit.DAYS);
}4.2 Lua 原子扣减与回滚
Redis 扣减库存必须使用 Lua 脚本保证原子性,防止超卖:
lua
-- stock_decr.lua
-- KEYS[1]: seckill:stock:{seckillId} 库存 key
-- KEYS[2]: seckill:user:{seckillId} 已购买用户集合 key
-- ARGV[1]: userId 用户 ID
-- ARGV[2]: quantity 购买数量
-- 检查用户是否已购买(一人一单限制)
local hasBought = redis.call('sismember', KEYS[2], ARGV[1])
if hasBought == 1 then
return -2 -- 已购买,不可重复下单
end
-- 获取当前库存
local stock = redis.call('get', KEYS[1])
if not stock then
return -3 -- 库存 key 不存在(秒杀未开始或已结束)
end
stock = tonumber(stock)
local quantity = tonumber(ARGV[2])
if stock < quantity then
return -1 -- 库存不足
end
-- 扣减库存
redis.call('decrby', KEYS[1], quantity)
-- 记录已购买用户
redis.call('sadd', KEYS[2], ARGV[1])
return 0 -- 扣减成功库存回滚(下单失败或取消时):
lua
-- stock_incr.lua
-- KEYS[1]: seckill:stock:{seckillId}
-- ARGV[1]: userId
-- ARGV[2]: quantity
-- 加回库存
redis.call('incrby', KEYS[1], ARGV[2])
-- 移除已购买记录
-- 注意:实际生产环境中,回滚逻辑根据业务决定是否需要移除购买记录
-- 通常超时未支付才回滚,且在一定时间内限制重新购买
return 0java
public boolean decrementStock(Long seckillId, Long userId, int quantity) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/stock_decr.lua")));
script.setResultType(Long.class);
List<String> keys = Arrays.asList(
"seckill:stock:" + seckillId,
"seckill:user:" + seckillId
);
Long result = redisTemplate.execute(script, keys, userId.toString(), String.valueOf(quantity));
if (result == null) {
return false;
}
switch (result.intValue()) {
case -3:
throw new BusinessException("秒杀尚未开始或已结束");
case -2:
throw new BusinessException("您已参与过该秒杀活动,不能重复购买");
case -1:
throw new BusinessException("库存不足");
case 0:
return true;
default:
return false;
}
}4.3 秒杀令牌机制
为避免用户在抢购开始前就拿到秒杀地址,采用动态令牌机制:
java
public String generateSeckillToken(Long userId, Long seckillId) {
// 校验秒杀是否在进行中
SeckillActivity activity = seckillActivityMapper.selectById(seckillId);
if (activity == null || activity.getStatus() != 1) {
throw new BusinessException("秒杀活动不存在或未开始");
}
// 生成随机 token
String token = UUID.randomUUID().toString().replace("-", "");
String tokenKey = "seckill:token:" + seckillId + ":" + userId;
// 存入 Redis,设置 TTL 为 5 分钟
redisTemplate.opsForValue().set(tokenKey, token, 5, TimeUnit.MINUTES);
return token;
}用户抢购请求需携带 token,服务端校验合法后才允许扣减库存:
java
public boolean validateToken(Long seckillId, Long userId, String token) {
String tokenKey = "seckill:token:" + seckillId + ":" + userId;
String storedToken = redisTemplate.opsForValue().get(tokenKey);
if (storedToken == null || !storedToken.equals(token)) {
return false;
}
// 验证通过后立即删除 token,防止重复使用
redisTemplate.delete(tokenKey);
return true;
}4.4 消息队列异步落单
Redis 扣减库存成功后,发送 RocketMQ 消息异步创建订单:
java
public void doSeckill(Long userId, Long seckillId, Integer quantity, String token) {
// 1. 校验令牌
if (!validateToken(seckillId, userId, token)) {
throw new BusinessException("令牌无效或已过期");
}
// 2. Redis 预扣库存(Lua 原子操作)
if (!decrementStock(seckillId, userId, quantity)) {
throw new BusinessException("库存不足");
}
// 3. 发送异步下单消息
SeckillOrderMessage message = new SeckillOrderMessage();
message.setUserId(userId);
message.setSeckillId(seckillId);
message.setQuantity(quantity);
message.setCreateTime(System.currentTimeMillis());
rocketMQTemplate.convertAndSend("seckill-order-topic", message);
}消费者处理消息,创建数据库订单并扣减 DB 库存:
java
@Component
@RocketMQMessageListener(topic = "seckill-order-topic", consumerGroup = "seckill-order-consumer")
public class SeckillOrderConsumer implements RocketMQListener<SeckillOrderMessage> {
@Override
public void onMessage(SeckillOrderMessage message) {
// 1. 幂等校验:防止重复消费
String idempotentKey = "seckill:order:" + message.getSeckillId() + ":" + message.getUserId();
Boolean notExist = redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1", 3, TimeUnit.DAYS);
if (Boolean.FALSE.equals(notExist)) {
log.info("重复消费,跳过处理: {}", message);
return;
}
// 2. 创建订单
SeckillOrder order = new SeckillOrder();
order.setOrderNo(generateOrderNo());
order.setUserId(message.getUserId());
order.setSeckillId(message.getSeckillId());
order.setQuantity(message.getQuantity());
order.setStatus(0); // 待支付
seckillOrderMapper.insert(order);
// 3. 扣减 DB 库存
int affected = seckillStockMapper.decrementStock(message.getSeckillId(), message.getQuantity());
if (affected == 0) {
// DB 库存不足,回滚订单并记录告警
order.setStatus(-1);
seckillOrderMapper.updateById(order);
log.error("DB 库存扣减失败: {}", message);
return;
}
// 4. 设置订单超时未支付自动取消(延迟消息)
rocketMQTemplate.syncSend("order-timeout-topic",
order.getOrderNo(), 300_000); // 5 分钟后超时
}
}5. 秒杀防刷
5.1 用户维度频率限制
基于 Redis 的滑动窗口算法,限制单个用户在单位时间内的请求次数:
lua
-- rate_limit.lua
-- KEYS[1]: seckill:rate:{userId}:{seckillId}
-- ARGV[1]: 窗口大小(秒)
-- ARGV[2]: 限制次数
-- ARGV[3]: 当前时间戳(毫秒)
local windowMs = tonumber(ARGV[1]) * 1000
local maxRequests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 移除窗口外的记录
redis.call('zremrangebyscore', KEYS[1], 0, now - windowMs)
-- 获取当前窗口内的请求数
local current = redis.call('zcard', KEYS[1])
if current >= maxRequests then
return 0 -- 限流,拒绝请求
end
-- 记录当前请求
redis.call('zadd', KEYS[1], now, now .. ':' .. math.random())
redis.call('expire', KEYS[1], tonumber(ARGV[1]))
return 1 -- 通过java
public boolean isRateLimited(Long userId, Long seckillId) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/rate_limit.lua")));
script.setResultType(Long.class);
String key = "seckill:rate:" + userId + ":" + seckillId;
List<String> keys = Collections.singletonList(key);
// 5 秒内最多请求 3 次
Long result = redisTemplate.execute(script, keys, "5", "3", String.valueOf(System.currentTimeMillis()));
return result != null && result == 0;
}5.2 设备指纹
通过前端采集设备信息生成指纹,服务端校验同一设备是否异常高频访问:
java
public class DeviceFingerprint {
// 前端采集信息:User-Agent、屏幕分辨率、Canvas 指纹、WebGL 信息、时区、语言
// 服务端生成指纹摘要
public String generateFingerprint(Map<String, String> deviceInfo) {
String raw = deviceInfo.get("userAgent") + "|"
+ deviceInfo.get("screenResolution") + "|"
+ deviceInfo.get("canvasFp") + "|"
+ deviceInfo.get("timezone");
return DigestUtils.sha256Hex(raw);
}
public boolean isSuspiciousDevice(String fingerprint) {
String key = "seckill:device:" + fingerprint;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.HOURS);
}
// 同一设备指纹 1 小时内请求超过阈值则标记为可疑
return count > 100;
}
}5.3 秒杀接口动态 URL
每次用户获取秒杀页面时,后台生成带有动态路径的接口地址,防止提前编写脚本攻击:
java
public String generateDynamicUrl(Long seckillId, Long userId) {
// 生成随机 path
String path = DigestUtils.md5Hex(seckillId + "_" + userId + "_" + System.currentTimeMillis());
path = path.substring(0, 16);
// 缓存映射关系
String mappingKey = "seckill:url:" + path;
redisTemplate.opsForValue().set(mappingKey, seckillId + ":" + userId, 10, TimeUnit.MINUTES);
// 返回动态 URL
return "/seckill/" + path + "/execute";
}5.4 验证码
秒杀下单前要求输入验证码,进一步提高刷单门槛:
java
public BufferedImage generateCaptcha(Long seckillId, Long userId) {
// 生成 4 位随机验证码
String code = String.format("%04d", new Random().nextInt(10000));
// 存入 Redis 并设置 60 秒过期
String key = "seckill:captcha:" + seckillId + ":" + userId;
redisTemplate.opsForValue().set(key, code, 60, TimeUnit.SECONDS);
// 使用 Java 2D API 生成验证码图片(包含干扰线、噪点、扭曲)
BufferedImage image = new BufferedImage(120, 40, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, 120, 40);
g.setFont(new Font("Arial", Font.BOLD, 24));
// ... 绘制验证码字符、干扰线、噪点
g.dispose();
return image;
}
public boolean validateCaptcha(Long seckillId, Long userId, String inputCode) {
String key = "seckill:captcha:" + seckillId + ":" + userId;
String storedCode = redisTemplate.opsForValue().get(key);
if (storedCode == null || !storedCode.equalsIgnoreCase(inputCode)) {
return false;
}
redisTemplate.delete(key);
return true;
}6. 秒杀极致优化
6.1 页面静态化 + CDN 加速
秒杀商品详情页在活动开始前即生成静态 HTML,部署到 CDN 节点:
静态资源(CDN):
- /seckill/css/seckill.css
- /seckill/js/seckill.js
- /seckill/html/item_{id}.html -- 商品详情静态页
动态数据(异步接口):
- /api/seckill/{id}/remain -- 获取剩余库存(轮询)
- /api/seckill/{id}/time -- 获取服务端当前时间(倒计时校准)
- /api/seckill/{id}/url -- 获取动态秒杀 URL(需登录)6.2 Redis 集群抗高并发
- 采用 Redis Cluster 或者阿里云 Tair 集群版。
- 库存 key 使用 HashTag 确保秒杀商品的库存 key 落在同一分片,保证 Lua 脚本原子性。
- 读写分离:主节点负责写操作(扣减库存),从节点负责读操作(查库存展示)。
- 连接池调优:设置合理的
maxTotal、maxIdle、minIdle避免连接成为瓶颈。
yaml
# application.yml
spring:
redis:
cluster:
nodes:
- 192.168.1.10:6379
- 192.168.1.11:6379
- 192.168.1.12:6379
lettuce:
pool:
max-active: 500
max-idle: 100
min-idle: 50
max-wait: 100ms6.3 异步削峰
使用 RocketMQ 将瞬时高并发的下单请求转化为平稳的异步处理流程:
- 普通优先级:常规订单。
- 高优先级:秒杀订单(使用单独的消息队列或单独的 consumer group)。
- 延迟消息:订单超时未支付自动取消,释放库存。
- 消息积压监控:队列堆积超过阈值时触发告警,自动扩容消费者。
请求峰值 (10万 QPS)
|
v
Redis 预减库存 (过滤 90% 请求)
|
v
RocketMQ 消息队列 (削峰,输出 ~2000 TPS)
|
v
消费者异步创建订单 (2000 TPS)
|
v
MySQL 批量写入 (批处理聚合,提升吞吐)6.4 读写分离数据库支撑
- 写库(主库):订单插入、库存扣减。
- 读库(从库):秒杀活动列表查询、商品信息查询。
- 订单表按秒杀活动 ID 分表(
seckill_order_0 ~ seckill_order_15),通过seckill_id % 16路由。 - 对账、统计等非实时查询走离线库(ClickHouse / Elasticsearch),不占用在线数据库资源。
java
@Component
public class SeckillOrderRouter {
public String getTableName(Long seckillId) {
int route = (int) (seckillId % 16);
return "seckill_order_" + route;
}
}6.5 完整秒杀请求链路时序
用户 浏览器 Nginx 应用服务 Redis RocketMQ MySQL
| | | | | | |
| 1.请求秒杀页面 | | | | | |
|-------------------------->| | | | | |
| | 2.请求静态资源 CDN | | | | |
| |---------------------->| | | | |
| | 3.返回静态页面 | | | | |
| |<----------------------| | | | |
| | 4.AJAX 获取动态 URL | | | | |
| |---------------------->|----------------->| | | |
| | | | 5.生成并缓存 Token | | |
| | | |--------------------->| | |
| | 6.返回动态 URL + Token | | | | |
| |<----------------------|<-----------------| | | |
| 7.用户点击抢购 | | | | | |
|-------------------------->| | | | | |
| | 8.前端限流校验(1s/次) | | | | |
| | 9.请求动态 URL | | | | |
| |---------------------->| 10.Nginx 限流 | | | |
| | |----------------->| | | |
| | | | 11.校验 Token | | |
| | | | 12.限流校验 | | |
| | | | 13.Lua 扣减库存 | | |
| | | |--------------------->| | |
| | | | 14.扣减成功 | | |
| | | |<---------------------| | |
| | | | 15.发送 MQ 消息 | | |
| | | |---------------------------------------->| |
| | 16.返回"排队中" | | | | |
| |<----------------------|<-----------------| | | |
| | | | | 17.消费消息 | |
| | | | |------------------>| |
| | | | | | 18.订单入库 |
| | | | | |--------------->|
| 19.前端轮询查询订单状态 | | | | | |
|-------------------------->|---------------------->|----------------->| | | |
| | | | 20.查询订单结果 | | |
| | | |<------------------------------------------| |
| | 21.返回订单结果 | | | | |
| |<----------------------|<-----------------| | | |
|<--------------------------| | | | | |