前言:在一些商城项目中,秒杀是不可或缺的。但是,如果将普通的购买、消费等业务流程用于秒杀系统,不做任何的处理,会导致请求阻塞严重、超买超卖等严重后果,服务器、数据库也可能因为瞬时的流量而奔溃。所以,设计一套高可用的秒杀系统,是尤为重要的。
一、技术选型阶段
在选型时,应尽可能用市面上主流、稳定的开发框架,以便于后续的维护与升级。本系统主要选用了以下框架来实现。
Springboot
用于基础架构Redis
、Redission
用于分布式锁、缓存商品数据RabbitMQ
用于异步处理订单,流量削峰Guava
用于限流
二、业务详解
1. 业务流程图
整体的业务流程图如下,细节我将在下文说明。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xfe38664-1646799142701)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(1)]-2ab191977b294f15a50fff7c45eb64d2.png)
2. 原理解析
2.1 接口层
1. 幂等性校验:
由于业务需求,每个用户只能购买一次商品,所以首先要进行幂等性校验,防止用户重复提交。
通常情况下,前端在用户点击购买后,会禁用购买按钮,用户无法提交第二次。但是,若用户在第一次提交之后发生了网络延迟,客户端错判认为请求未提交,这样就造成了两次相同的请求提交。其实,这种情况也会发生在表单提交等场景。
常见的解决思路有:
-
在数据库中将用户id设置唯一索引,这样就无法插入重复的记录。
-
利用分布式锁,用户请求后在Redis等缓存中设置锁,第二次请求到达时就无法获取到锁,直接返回失败。
-
令牌机制(
token
),客户端在请求进入秒杀页面时,服务端生成一个token
发送给客户端。客户端在请求抢购时将token
携带在请求头或者cookie
中发送到服务端。服务端判断缓存或者Session
中是否包含此token
。若有,则将此令牌移除,并执行业务操作;若没有,则返回下单失败。上述的三种解决方式,第一种是数据库级别的,后两种是业务层的。针对于本系统,第二种无论在代码的实现还是效率上都要更好。
2. Redis库存预热与预减
在秒杀时,如果商品数据存放在数据库中,短时间内可能有大量的请求访问数据库,造成数据库奔溃。
解决方案:
-
在秒杀开始前,服务器提前主动将商品数据预热到Redis中,在接口层直接对Redis中的库存进行判断及预减。
-
Redis中关于本次秒杀的数据应设置较长的过期时间(至少要在本次秒杀结束之后过期),否则容易产生缓存击穿等问题。
在进行缓存预减的操作时,分为两步,第一步是判断Redis中的库存是否充足,第二步是减库存。虽然在Redis中每一条指令都是原子性的,但是上述的两个步骤如果不做处理,那它们在Redis中并不是原子性的。
即有可能发生以下这种情况:
可以看到,由于库存判断与减库存这两步操作不是一个原子性的整体,当线程A、B同时判断到库存充足时,双方都进行减库存,造成了多销的情况。
解决方案:
- 使用
Lua
脚本,由于Lua
脚本在Redis内部是原子性的,所以执行库存判断与减库存整体是一个原子操作,不会出现上述问题。 - 使用Redis分布式锁,使用
setnx
命令可以设置一个分布式锁,线程每次进行库存判断前,获取锁,进行减库存后,释放锁。这样就能保证这两步操作的原子性。关于分布式锁,在下文会详解。
3. 分布式锁
上文提到,为了保证对Redis两步操作的原子性,可以采用分布式锁来实现。那何为分布式锁呢?首先,我们要知道传统的非分布式锁有什么缺陷。
想要在java中保证某一个代码段的原子性,可以使用sychronized
关键字来实现。如果使用sychronized
,在单机环境下一般不会有什么问题,但若是在集群部署的环境下,由于各个服务节点的进程内缓存是不会共享的,就造成了锁失效的问题。
解决方案:
-
使用Redis实现分布式锁
-
使用
zookeeper
实现分布式锁这里使用Redis来实现,具体的实现方案如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XDKuHs06-1646799142703)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(2)]-7b0551431dea406684d03faa51548d72.png)
这里有几个问题要解释:
- 为什么要设置锁自动续期?
设置锁的自动过期是为了防止一个线程获取了锁,但是运行过程中发生了故障,导致锁无法释放。
针对上述问题,可以通过设置锁的时候设置过期时间解决,但是这存在两点不足的地方。
-
当获取锁的线程业务还没有执行完,锁过期时间到了,导致锁失效。
-
当获取锁的线程发生故障,其他线程只有等到锁到达过期时间了才能获取到锁,造成长时间阻塞。
针对上述问题,就提出了锁的自动续期机制:
当线程A获取锁之后,先给锁设定一个初始的过期时间(30s),随后启动一个定时任务(watch dog
),每隔一段时间来检测线程A是否还占有这把锁,如果还占有则不断刷新过期时间。
这样子,假设线程A在执行过程中发生异常,到达过期时间后就不会进行续期,锁就可以被其他线程获取到了。
通过这种方案,就解决了上述两点问题。
-
为什么在解锁的时候要判断是不是自己的锁?
我们可以设想一下这种情况:
当线程A获取了锁,但是在解锁之前线程A因为某些不可抗拒因素阻塞住了。这个时候,线程B只能等待A主动释放锁或者锁到了过期时间,自动释放。假设B在锁到达过期时间后获取了这把锁,然后开始处理自己的业务了。但是这个时候,之前阻塞的线程A又恢复正常了,当他处理完自己的业务后,自然而然的把锁给解掉了。
这样,在线程B的业务还没有处理完之前,A就将锁解掉了。那么这个时候,如果又有新的线程访问,就造成了业务的同时执行,也就是锁失效。上述流程可以由下图来体现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qy5RJcAf-1646799142703)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(3)]-57eb179124d541acadac995252b3f794.png)
针对上述问题,有如下解决方案:
首先,我们来看看原先redis分布式锁的结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sn1AkCFZ-1646799142704)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(5)]-3621a0454ce345a9bb6c68e473f6d357.png)
原先,我们在使用分布式锁的时候,只需要根据key的值来加锁,解锁就可以了。value可以设定为任意的值。这样子,其实对每一个线程来说,都是具有加锁与解锁的权利的。
为了不让别的线程解掉当前线程加的锁,我们可以在value中存储当前的线程ID。
就有了如下的锁结构。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ITYLHdiM-1646799142704)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(6)]-1727b3cc2de549c5bb1611ee5e61b46f.png)
于是,针对这种的所结构,每个线程只需要在加锁的时候将自己ID记录到value中,解锁的时候先进行判断,只有当锁中的ID与自己的ID一致的时,才能解锁。
范例如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GBAvPMSj-1646799142705)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(10)]-6f86a7f779e147aabcc3e9e4c40a7303.png)
到这里,就已经可以解决本系统的需求了。但是其实锁的结构还可以继续进行优化,这里做额外的补充。
因为与本系统业务无关,所以读者也可直接跳过此部分。
假设有这样一种情况,业务A需要获取锁,业务B也需要获取同一把锁。
如果在业务A中调用了业务B,会出现问题吗?
答案是肯定的,因为业务A在执行的时候,已经获取了锁,但是自己还没有释放。这个时候调用业务B,就又要尝试去获取锁。可是这把锁在业务A主动释放前,是获取不到的。这个时候,整个业务就会卡死在这一步,这就是死锁的情况。
那么,我们如何来解决这一问题呢?
基于上述问题,就有了如下的锁结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KOFFjmYD-1646799142705)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(4)]-53e52dedec5548eebc5326f7095c89b7.png)
将锁设置为hash
的结构,hash
中的field
设置为线程ID,value
设置为加锁的次数。我们将这种锁称为可重入锁。
具体的加锁逻辑如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UIfv9bov-1646799142705)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(8)]-b228dfc37cea42669a00930f09c9c862.png)
具体的解锁逻辑如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-srLVXlfa-1646799142706)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(9)]-262ee820dad64d10962a0c7e5d8eb8fe.png)
设置了可重入锁之后,业务A获取了锁,调用业务B时,B也能正常获取到锁。B处理完后解锁,这个时候锁的加锁次数为1,锁不会失效。A处理完业务后解锁,加锁次数为0,锁就可以正常释放了。
综上,我们就解决了分布式锁的一些问题。
2.2 业务层
1. 前置操作
在到达业务层后,首先要进行订单对象的封装、Redis中生成订单对象码的操作。
-
为什么要主动生成订单号,不能用数据库自增吗?
首先,如果我们使用数据库自增作为订单ID,那么就无法实现订单状态的追踪。我们在业务层就生成一个订单号,后续在Redis以订单号为key设置此订单的状态信息,例如:未处理、已处理、下单成功、下单失败等。这样,在随后的处理中,就可以获取这条订单的状态,基于它来实现防止重复消费、用户获取订单状态等业务。关于具体的实现,将在下文做解释。
其次,使用数据库自增作为ID,由于数据库在做ID递增时会获取自增锁,这样就存在一个效率的问题。而且,在数据库集群分表的情况下,又要使用到分布式自增主键的技术,增加了业务实现的困难程度。 -
为什么使用UUID作为主键?
因为UUID是不会重复的,或者说概率极小。其实这里还可以使用其他的算法生成主键。 -
为什么要在Redis中设置状态码?
为了实现对订单状态的追踪,防止重复消费等问题。并且后续用户可以直接轮询Redis来查看秒杀结果。2. 投递消息到消息队列
在进行完前置操作后,要将消息投放到消息队列中。
-
为什么要将消息投递到消息队列中?
倘若不使用MQ进行处理,只有当上一个订单处理完成之后,才能处理下一个订单。这样子,大量的用户请求就会阻塞,可能会造成订单的丢失。更严重的,是会造成服务器或数据库的崩溃。
用户由于长时间的阻塞,不能及时接收到响应,就无法判断此次请求是否成功,用户的体验感也会下降。
MQ是一种消息队列,使用MQ将同步处理消息转为异步处理。
我们在将消息投递到MQ后,就完成对这条请求的响应,这样解决了请求阻塞的问题。当MQ监听者监听到消息之后,再完成入库等操作。这样,服务器就能以一种平滑的方式处理订单。 不会因为压力过大而出现问题。 -
投递失败怎么办?
在进行消息投递的时候,可能由于网络波动造成与MQ的连接异常,导致投递失败。所以,在这里设置了重试机制。
当投递失败后,会再次进行投递,当投递次数到达3次后,将订单对象存入到Redis中,交由后续处理。
这样,就保证了消息的安全性。3. 返回订单号给用户
将订单号返回给用户,用于后续轮询订单的结果。
2.3 MQ消费者
1. 一些前置的判断操作
在消费者监听到新的消息后,会先进行一些前置操作,包括订单状态码判断、库存是否充足、订单是否已提交等操作。
- 为什么要判断订单状态码?
试想以下这种两种情况:
-
生产者尝试发送消息到消息队列,消息已经发送。但是MQ在返回发送成功响应时发生了网络错误,导致生产者误认为消息没发送成功,进行重复发送。
-
消费者监听到了新的消息,已进行完了业务的操作,但是在最后返回ack的时候发生了异常,导致MQ认为消息未被消费成功。这样,消息又会被重新消费。
为了解决上述问题,在Redis中设置了订单状态码机制。
当业务层接受到订单后,设置此订单的状态为未处理
;当消费者消费成功后,将次订单的状态设置为成功
;消费失败后,将状态设置为失败
。
消费者监听到消息后,先对订单状态进行判断。如果是未处理
,则继续进行后续判断;如果是成功
或失败
,则不进行后续处理,直接返回ack。
综上,设置状态码在本阶段的主要作用是防止处理重复订单
-
为什么要判断订单是否重复?
试想这样一种情况:
若消费者监听到消息,并已完成入库的操作,但是此时还没来得及设置Redis中的订单状态码,消费者发生了故障。MQ长时间没有得到ack,认为消费者消费失败,这条消息就会被重复消费。所以通过此次判断避免对同一订单重复进行入库操作。
2. 入库操作事务控制
在进行入库操作时,分为两个修改库存和添加订单两个环节。我们要保证这两个操作的原子性,就可以使用事务控制。如果其中有一个出现异常,就会回滚数据,并抛出异常。MQ也会对此消息进行再次的消费。
2.4 MQ失败重试机制
-
为什么要设置失败重试?
试想下面这种情况:
MQ在进行订单入库的时候,与数据库的连接发生了异常。此时事务会回滚,消费是一个失败的状态。如果不进行失败重试,这条订单就会被“丢弃”。用户也将无法查询到订单的结果。
显然这是一种严重的情况,为了避免这种情况,设置了失败重试的机制。具体的实现方案如下:
-
若消费者消费成功,消息出队
-
若消费者消费失败(出现异常),未响应ack。
-
MQ指定时间内未接受到ack,或者消费者抛出异常,尝试重新消费。
-
MQ尝试消费3次后,任然失败,就将消息转发到死信队列中。
-
私信队列中的消息等待后续处理。
2.5 用户查询订单结果
-
用户下单后,接收到订单号,怎样知道自己有没有下单成功?
有如下的方案:
-
前端轮询订单号,服务端从redis中获取订单情况返回给用户。若Redis中订单状态码为已处理,则继续去数据库中返回详细的信息。
-
服务器与客户端建立websocket连接,服务器在订单处理后主动通知客户端。
-
用户下单后,接口层设置轮询数据库,返回结果给用户。(不推荐)
本系统采用第一种方案解决。
2.6 接口限流方案
在开放秒杀接口后,加入瞬时的请求超过了服务器能处理请求的最大值,服务器就容易造成问题,请求也将无法正常响应。
所以,常见的解决方案就是使用接口限流。限流的实现方案有很多,这里提供3种解决方案。
1. 计数器限流
设定一个每秒请求次数的上限值,每次请求访问后,就将数加1。当到达次数之后,开启服务降级,不再处理请求。当下一秒开始后,计数器重新清0,重新开始计数。
2. 漏桶限流
创建一个大小恒定的漏桶存放请求,这个漏桶不限制请求进入的速率,请求以恒定的速率从桶底流出。若漏桶已经满了,后进入的请求将会溢出。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xl0zsSN8-1646799142707)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(11)]-0d1c3ef094e9438baa4c6db80da00f29.png)
3. 令牌桶限流
创建一个大小恒定的令牌桶,客户端维护这个令牌桶,以恒定的速率往桶中放令牌。每一次请求到达接口后,就从令牌桶中取走一个令牌。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-94jM7gRn-1646799142707)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(12)]-cfbc357b20a548f2b0db4b3881873d28.png)
若令牌桶中的令牌满了,则停止放入令牌。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J60oeUvm-1646799142708)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(13)]-ec8de9992c894112ae816bc1bcfc6379.png)
若令牌桶中的令牌空了,则拒绝请求的访问。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZVlBNRQO-1646799142708)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(14)]-7045d6ed80054cbdbc4edf5f26775054.png)
本系统选用了令牌桶的方案来实现限流。
三、代码实现
3.1 自定义注解实现幂等性校验
/**
* 接口幂等性锁注解
* @author Chenpeman
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface UniqueLock {
}
/**
* 接口幂等性锁
* 防止请求重复提交
* @author Chenpeman
*/
@Slf4j
@Aspect
@Component
public class UniqueLockAop {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
/**
* Around object.
*
* @param joinPoint the join point
* @return the object
* @throws Throwable the throwable
*/
@Around("@annotation(com.example.seckill_demo.entity.UniqueLock)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
//获取请求参数
Object[] args =joinPoint.getArgs();
//生成锁ID
String lockKey="seckill:"+ args[0] +":"+args[1];
//设置锁
Boolean b = redisTemplate.opsForValue().setIfAbsent(lockKey, 1);
if(Boolean.TRUE.equals(b)){
return joinPoint.proceed();
}else {
throw new GetUniqueLockException("获取锁失败");
}
}
}
3.2 分布式锁+缓存预减
/**
* 秒杀接口
* @author Chenpeman
*/
@RestController
@Slf4j
public class SeckillController {
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Autowired
private OrderService orderService;
/**
* 秒杀接口
*
* @param userId 用户id
* @param productId 商品id
* @param number 数量
* @return 请求结果
* @throws InterruptedException the interrupted exception
*/
@PostMapping("/seckill")
@Limit(key = "seckill", permitsPerSecond = 10000, timeout = 500, timeunit = TimeUnit.MILLISECONDS)
@UniqueLock
public Result<String> seckillProduct(Long userId,Long productId,Long number) throws InterruptedException {
RLock lock = redissonClient.getLock("seckill:lock:product:" + productId);
try{
lock.lock();
Integer remain = (Integer) redisTemplate.opsForValue().get("seckill:remain:product" + productId);
if(remain==null || remain<number) return Result.makeRsp(RetCode.FAIL,RetMsg.FAIL,"当前商品已售空");
redisTemplate.opsForValue().decrement("seckill:remain:product" + productId);
}catch (Exception e){
return Result.makeRsp(RetCode.INTERNAL_SERVER_ERROR,RetMsg.INTERNAL_ERROR,"服务器内部异常");
}finally {
lock.unlock();
}
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setUserId(userId);
seckillOrder.setProductId(productId);
seckillOrder.setNumber(number);
return Result.makeOKRsp(orderService.seckill(seckillOrder));
}
}
3.3 业务层代码
package com.example.seckill_demo.service;
import com.example.seckill_demo.entity.SeckillOrder;
/**
* 订单业务接口
* @author Chenpeman
*/
public interface OrderService {
/**
* 秒杀商品
*
* @param seckillOrder 订单对象
* @return 订单号
* @throws InterruptedException the interrupted exception
*/
public String seckill(SeckillOrder seckillOrder) throws InterruptedException;
/**
* 在redis中设置订单的状态码
*
* @param id 订单id
* @param status 状态码
*/
public void setRedisStatus(String id,int status);
/**
* 订单(秒杀)业务
* @author Chenpeman
*/
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private ProductMapper productMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Override
public String seckill(SeckillOrder seckillOrder) throws InterruptedException {
//封装订单对象
seckillOrder.setCreateTime(new Date());
seckillOrder.setId(UUID.randomUUID().toString());
//Redis中生成订单状态码
//0:未处理 1:成功 -1:失败
setRedisStatus(seckillOrder.getId(),0);
//消息发送到MQ 尝试发送3次
boolean sendResult=false;
for(int i=0;i<3;i++){
try{
rabbitTemplate.convertAndSend("seckill.queue", seckillOrder);
sendResult=true;
log.info("新订单投递成功");
break;
}catch (Exception e){
log.error("第[{}]次尝试投递新订单失败",i);
Thread.sleep(200);
}
}
//发送订单到MQ失败 失败订单存入Redis 后续统一处理
if (!sendResult){
redisTemplate.opsForHash().put("sendErrorOrders",seckillOrder.getId(), JSONObject.toJSONString(seckillOrder));
log.error("订单[{}]发送到MQ失败",seckillOrder.getId());
throw new SendToMQException("发送消息到MQ失败");
}
return seckillOrder.getId();
}
@Override
public void setRedisStatus(String id,int status){
redisTemplate.opsForValue().set("seckill:"+id,status);
}
}
3.4 MQ消费者+入库事务
/**
* MQ监听类
* @author Chenpeman
*/
@Component
@Slf4j
public class RabbitListener {
@Autowired
private OrderService orderService;
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Autowired
private OrderMapper orderMapper;
@Autowired
private ProductMapper productMapper;
/**
* 处理订单消息
*
* @param seckillOrder 秒杀订单对象
*/
@RabbitListener(queues = "seckill.queue")
public void handleOrder(SeckillOrder seckillOrder){
log.info("消费者接收到新订单消息[{}]",seckillOrder.getId());
//判断此订单是否已被处理
Integer i =(Integer) redisTemplate.opsForValue().get("seckill:" + seckillOrder.getId());
if(i==null){
//判断redis中没有状态码,判断为超时的消息
throw new ConsumerException("发现超时未处理的订单");
}else if(i==0){
log.info("发现未处理的订单[{}]",seckillOrder.getId());
//判断此订单是否已入库
SeckillOrder so = orderMapper.selectById(seckillOrder.getId());
if(so!=null){
orderService.setRedisStatus(seckillOrder.getId(),1);
log.error("[{}]订单已成功入库,请勿重复消费",seckillOrder.getId());
return;
}
//判断此商品是否存在
Product product = productMapper.selectById(seckillOrder.getProductId());
if(product==null){
orderService.setRedisStatus(seckillOrder.getId(),-1);
log.error("不存在ID=[{}]的商品",seckillOrder.getProductId());
return;
}
//判断商品库存是否充足
if (product.getRemain()<seckillOrder.getNumber()){
orderService.setRedisStatus(seckillOrder.getId(),-1);
log.error("商品[{}]的库存不足",seckillOrder.getProductId());
return;
}
//满足条件,进行订单入库
//若发生错误,重新消费,累计三次重复消费后,消息被投送到死信队列
orderService.insertOrder(seckillOrder);
}
}
}
@Transactional
public void insertOrder(SeckillOrder seckillOrder) {
//再确认一下判断商品库存是否充足
Product product = productMapper.selectById(seckillOrder.getProductId());
if (product.getRemain()<seckillOrder.getNumber()){
setRedisStatus(seckillOrder.getId(),-1);
log.error("商品[{}]的库存不足",seckillOrder.getProductId());
throw new ProductNotEnoughException("商品库存不足");
}
//修改商品库存
Long remain = product.getRemain();
Long sales = product.getSales();
remain=remain-1;
sales=sales+1;
product.setRemain(remain);
product.setSales(sales);
int i1 = productMapper.updateById(product);
if (i1!=1){
log.error("[{}]商品库存修改失败,回滚数据",seckillOrder.getProductId());
throw new ProductUpdateException("商品库存修改失败");
}
//订单信息入库
int i2 = orderMapper.insert(seckillOrder);
if(i2!=1) {
log.error("[{}]订单入库失败,回滚数据",seckillOrder.getId());
throw new OrderInsertException("订单入库失败");
}
//修改Redis状态码
setRedisStatus(seckillOrder.getId(),1);
}
3.5 MQ消费重试+发送到死信队列
rabbitmq:
host:
username:
password:
virtual-host: /
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初始的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
/**
* Mq配置
*
* @author Chenpeman
*/
@Configuration
public class RabbitMqConfig {
/**
* 采用json传输数据
*
* @return the message converter
*/
@Bean
public MessageConverter jsonMessageConverter(){
return new Jackson2JsonMessageConverter();
}
/**
* 秒杀订单处理队列
*
* @return the queue
*/
@Bean
public Queue seckillQueue() {
return new Queue("seckill.queue", true);
}
/**
* 消费失败队列
*
* @return the queue
*/
@Bean("error_queue")
public Queue errorQueue(){
return new Queue("error_queue",true);
}
/**
* 处理错误消息的交换机
*
* @return the direct exchange
*/
@Bean("error_direct")
public DirectExchange errorMessageExchange(){
return new DirectExchange("error_direct");
}
/**
* 绑定错死信队列与交换机
*
* @param error_direct the error direct
* @param error_queue the error queue
* @return the binding
*/
@Bean
public Binding bindingError(DirectExchange error_direct, Queue error_queue){
return BindingBuilder.bind(error_queue).to(error_direct).with("error");
}
/**
* 配置失败自动投递到死信队列
*
* @param rabbitTemplate the rabbit template
* @return the message recoverer
*/
@Bean
public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate,"error_direct","error");
}
}
3.6 自定义注解实现接口限流
/**
* 接口限流注解
* @author Chenpeman
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
/**
* 资源的key,唯一
* 作用:不同的接口,不同的流量控制
*
* @return the string
*/
String key() default "";
/**
* 最多的访问限制次数
*
* @return the double
*/
double permitsPerSecond () ;
/**
* 获取令牌最大等待时间
*
* @return the long
*/
long timeout();
/**
* 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒
*
* @return the time unit
*/
TimeUnit timeunit() default TimeUnit.MILLISECONDS;
}
/**
* 接口限流切面
* @author Chenpeman
*/
@Slf4j
@Aspect
@Component
public class LimitAop {
/**
* 不同的接口,不同的流量控制
* map的key为 Limiter.key
*/
private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();
/**
* 切面
*
* @param joinPoint the join point
* @return the object
* @throws Throwable the throwable
*/
@Around("@annotation(com.example.seckill_demo.entity.Limit)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
//拿limit的注解
Limit limit = method.getAnnotation(Limit.class);
if (limit != null) {
//key作用:不同的接口,不同的流量控制
String key=limit.key();
RateLimiter rateLimiter = null;
//验证缓存是否有命中key
if (!limitMap.containsKey(key)) {
// 创建令牌桶
rateLimiter = RateLimiter.create(limit.permitsPerSecond());
limitMap.put(key, rateLimiter);
log.info("新建了令牌桶={},容量={}",key,limit.permitsPerSecond());
}
rateLimiter = limitMap.get(key);
// 拿令牌
boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
// 拿不到命令,直接返回异常提示
if (!acquire) {
log.debug("令牌桶={},获取令牌失败",key);
throw new ReachRequestLimitException("到达接口访问上限");
}
}
return joinPoint.proceed();
}
}
四、性能测试
我们使用Jmeter来做压力测试
- 启动服务
- 配置两个商品,库存均为1000,订单表初始为空
- 商品库存预热至Redis
- 配置Jmeter,线程数4000,2s内执行完,循环1次(测试两遍)
- 请求数据csv,模拟一个人发送2次抢购A商品,2次抢购B商品。1000个人,4000个请求。
- 查看发送数据
可以看到,请求均得到了及时的响应。让我们看看数据库的结果。
数据库中的数据正常售空,订单也没有任何的问题。
五、项目开源
https://gitee.com/chenpeman/seckilldemo
六、后记
尚有很多需要学习的地方,欢迎指出不足。
6799142708)]
- 配置两个商品,库存均为1000,订单表初始为空
[外链图片转存中…(img-VarAtovj-1646799142709)]
[外链图片转存中…(img-16jDOojU-1646799142709)]
- 商品库存预热至Redis
[外链图片转存中…(img-9Ka7P5gC-1646799142709)]
[外链图片转存中…(img-VVlKzdjz-1646799142710)]
- 配置Jmeter,线程数4000,2s内执行完,循环1次(测试两遍)
[外链图片转存中…(img-GXbmaMqO-1646799142710)]
- 请求数据csv,模拟一个人发送2次抢购A商品,2次抢购B商品。1000个人,4000个请求。
[外链图片转存中…(img-ev8cVy44-1646799142710)]
- 查看发送数据
[外链图片转存中…(img-LWvwQqTC-1646799142710)]
[外链图片转存中…(img-t7GhTDTK-1646799142711)]
[外链图片转存中…(img-i4J3KCEK-1646799142711)]
可以看到,请求均得到了及时的响应。让我们看看数据库的结果。
[外链图片转存中…(img-3HRQdWSs-1646799142711)]
[外链图片转存中…(img-AkgKW9Je-1646799142711)]
数据库中的数据正常售空,订单也没有任何的问题。
五、项目开源
https://gitee.com/chenpeman/seckilldemo
六、后记
尚有很多需要学习的地方,欢迎指出不足。