秒杀是一个常见的业务场景,其核心问题在于如何处理并发读和并发写,最近我在准备校招,自己根据所学的并发知识做了一个项目。首先项目地址附上 https://github.com/BlackCat2021/miaosha
项目整体介绍
项目中使用的技术栈有springboot、mybatis、redis、RocketMQ,以及众多工具包;项目主体是一个商城系统的秒杀模块,实现的功能有用户注册登录,商品的管理,以及商品下单的功能。项目中使用redis,guava cache做多级缓存,使用RocketMQ做异步解耦和分布式事务等,使用guava包中RateLimiter 实现了限流操作,最后使用nginx转发路由
项目中重点
限流操作
秒杀场景会在某一时刻突然涌入大量流量,如果不使用限流技术可能会导致应用直接挂掉,项目中将限流功能封装成了一个注解,在接口上加上一个注解就能实现限流操作
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface RateLimiter {
}
具体的限流功能在请求过滤器中实现
@Component("rateLimiterInterceptor")
public class RateLimiterInterceptor implements HandlerInterceptor {
private RateLimiter rateLimiter;
@PostConstruct
public void init() {
rateLimiter = RateLimiter.create(100);
// 每秒钟产生100个令牌,这里的值根据业务场景调整
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler;
com.cat.miaosha.common.annotations.RateLimiter annotation = hm.getMethodAnnotation(com.cat.miaosha.common.annotations.RateLimiter.class);
if(annotation != null){
if(!rateLimiter.tryAcquire(1)){
throw new BusinessException(ResultStatus.RATELIMITE);
}
}
}
return true;
}
}
这里使用的限流算法是令牌桶算法,它的优点是能灵活适应突发的流量,支持高并发场景,对用户的体验好,除此之限流算法还有固定窗口,滑动窗口,漏斗算法等,这里就不展开了,读者可以自行了解
下单流程
在秒杀场景下虽然有大量请求,但是多数请求都是无效的,所以我们通过redis执行lua脚本的原子性来拦下大量请求,只有获得信号量的少部分请求才会去访问数据库,此时有人会说如果redis中信号量与MySQL中的库存不一致怎么解决,我的回答是不用解决,在据的强一这种业务场景下很难保证数致性,而且也不需要保证的强一致性,上述方法能保证秒杀开始前redis信号量和MySQL库存相同的前提下不会出现库存超卖的问题,在业务方面商家做秒杀活动是为了做宣传并不是为了盈利所以即使出现少买也不会有什么问题,当然如果非要解决少卖的问题也可以通过运营人员人工补偿的方式解决。
整个过程使用到了RocketMQ做解耦那就不得不说RocketMQ的事务消息了。在异步处理的时候一定要解决事务的一致性问题,及是创建完订单成功后消息一定要发成功,RocketMQ就是解决这个问题,RocketMQ的事务消息是基于TCC实现的,具体原理请自行了解。
具体代码实现
// 执行本地事务
@Override
public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
String body = new String(messageExt.getBody());
Map<String, Object> msg = JsonUtils.jsonToObject(body, Map.class);
OrderDO order = (OrderDO)msg.get("order");
String id = order.getId();
OrderDO orderDO = orderDOMapper.selectByPrimaryKey(id);
if(orderDO == null){
return LocalTransactionState.ROLLBACK_MESSAGE;
}
return LocalTransactionState.COMMIT_MESSAGE;
}
@Override
public LocalTransactionState executeLocalTransaction(Message message, Object o) {
// 消息回查
String stockLogId = null;
int insertCount = 0;
try {
Map<String, Object> args = (Map) o;
OrderDO order = (OrderDO) args.get("order");
stockLogId = (String) args.get("stockLogId");
// 订单落库
insertCount = orderDOMapper.insertSelective(order);
} catch (Exception e) {
e.printStackTrace();
return LocalTransactionState.ROLLBACK_MESSAGE;
}
// 订单创建成功
if (insertCount > 0) {
StockLogDO stockLogDO = new StockLogDO();
stockLogDO.setStockLogId(stockLogId);
stockLogDO.setStatus(2);
logDOMapper.updateByPrimaryKeySelective(stockLogDO);
return LocalTransactionState.COMMIT_MESSAGE;
} else {
StockLogDO stockLogDO = new StockLogDO();
stockLogDO.setStockLogId(stockLogId);
stockLogDO.setStatus(3);
logDOMapper.updateByPrimaryKeySelective(stockLogDO);
// 订单创建失败
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
如何防止超卖
数据库层面
- 乐观锁机制
在查询时查出数据的版本号,带着版本号去更新 - 悲观锁
查询数据时使用当前读 (for update)使用数据库的行锁锁住数据,只有当前事务才能更新数据
redis层面
使用redis的原子扣减功能,可以理解为去redis中申请资源(p操作),只有申请到资源的才能操作数据库,所以这样是不会出现库存为负数的问题
多级缓存
缓存组件使用了redis和guava cache,redis缓存商品信息,guava cache缓存商品是否还有库存。多级缓存就是为了提到大量对操作的吞吐量。在提高吞吐量的同时会会出现数据不一致问题,例如商品信息更改而缓存中的数据没有更改的情况。可以通过分布式锁、延时双删、MQ异步删除的方式解决,具体实现请自行理解。
其他
上述仅仅是后端程序员能做的一部分,秒杀场景的解决方案是多方面协作的例如
- 前端页面静态化,将页面放置CDN结点上以减少对服务器的访问
- DNS负载均衡
- nginx多级负载均衡
- 隐藏秒杀接口
本篇只是为了介绍我自己项目中还算看看的点,所以有些知识没有展开讲,望各位大佬见谅,另外我附上另外一个大佬对秒杀的看法的博客三太子敖丙,如果大家对我的项目感兴趣欢迎移步github