1.秒杀业务性能测试
- 在测试类中添加如下方法,根据1000条用户信息生成1000条token
/**
* 执行generateToken相关业务逻辑。
*
* 作用:
* 1.数据库查询1000个用户信息;
* 2.创建字符输出流准备写入token到文件;
* 3.随即生成Token作为登录令牌;
* 4.隐藏User对象信息;
* 5.将User对象转换为HashMap存储;
*
* @return无返回值
*/
@Test
public void generateToken() throws IOException {
//数据库查询1000个用户信息
List<User> userList= userService.list(new QueryWrapper<User>().last("limit 1000"));
//创建字符输出流准备写入token到文件
BufferedWriter br=new BufferedWriter(new FileWriter("D:\\Work_develop\\idea_project\\hm-dianping\\tokens.txt"));
for (User user:userList){
//随即生成Token作为登录令牌
String token= UUID.randomUUID().toString();
//隐藏User对象信息
UserDTO userDTO= BeanUtil.copyProperties(user,UserDTO.class);
//将User对象转换为HashMap存储
Map<String,Object> map=BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));
//保存用户token到Redis,设置token有效期
String tokenKey= RedisConstants.LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,map);
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//写入token到文件
br.write(token);
br.newLine();
br.flush();
}
}执行结果如下,生成了1000条用户登录token到tokens.txt,方便JMeter读取

清空订单表,更改秒杀库存为200

Jmeter中线程数设为1000,执行时间为1秒,模拟1000个用户高并发访问秒杀业务
在CSV数据文件设置中进行如下设置

在HTTP信息头管理器中进行如下设置 
测试秒杀id为11的优惠券接口

测试结果

数据库200条数据正常扣减库存
2.异步秒杀业务流程
之前的秒杀业务流程:

- 查询优惠卷,是否在秒杀时间段,判断秒杀卷库存是否充足
- 查询订单,校验一人一单
- 扣减优惠卷库存,判断库存是否充足,防止超卖
- 将用户抢购的优惠卷信息写入订单,返回订单id
查询优惠卷,查询订单,扣减库存,创建订单,都是数据库操作,前两部对用户秒杀资格判断,后两步进行数据库写操作,耗时较久。
异步思路:
(1)判断秒杀卷库存和校验一人一单逻辑放到Redis中完成,在Redis中预先扣减库存
- 优化:将数据库查询操作改为Redis查询,将秒杀资格的判断分离,缩短业务流程 (2)保存优惠卷id、用户id、订单id到阻塞队列,将耗时较久的减库存、创建订单这两部去开启独立线程异步写入数据库
- 优化:异步读取队列中的订单信息,完成下单

- 那如何在Redis判断库存是否充足和一人一单呢?
把优惠卷库存信息和优惠卷被哪些用户买过的信息缓存在Redis中,我们应该选择什么养的数据结构保存这两个信息呐?
对于优惠卷库存信息,使用Stirng类型,Key为优惠卷库存key前缀+优惠卷id,value为库存
对于订单购买信息,使用Set类型,去重判断一人一单,key为优惠卷订单key前缀+优惠卷id,value为购买过该优惠卷的用户id集合

异步秒杀业务流程如下

- 使用lua脚本保证操作原子性:判断用户秒杀资格,根据不同情况返回不同标识(0:满足条件、1:库存不足、2:该优惠卷用户已下过单),如果满足秒杀条件,预先扣减Redis库存,数据库库存后面异步扣减,将用户id存入当前优惠卷的set集合,作为一人一单的依据。
- 在tomcat中,首先执行lua脚本,判断返回结果是否为0,如果没有购买资格返回提示信息,如果由购买资格,将优惠卷id、用户id以及订单id存入阻塞队列,方便线程异步读信息,完成下单,最后返回订单id,用户就可以拿订单id完成支付操作了。
- 开启异步线程读取阻塞队列中的信息,完成数据库下单和扣减库存动作,这一步对时效性要求就没有那么高了。
3.改进秒杀业务,提高并发性能
需求:
- 新增秒杀优惠卷同时,将优惠卷库存信息存入Redis
- 基于lua脚本,判断秒杀库存、一人一单、决定用户是否抢购成功
- 如果抢购成功,将优惠卷id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
- 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中。
/**
* 新增秒杀优惠券。
*
* 作用:
* 1.保存优惠券;
* 2.保存秒杀信息;
* 3.保存秒杀库存到Redis;
*
* @param voucher优惠券对象
* @return无返回值
*/
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
//保存秒杀库存到Redis
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY +voucher.getId(),voucher.getStock().toString());
}测试新增秒杀券,返回的秒杀券id为15
{
"shopId": 1,
"title": "100元代金券",
"subTitle": "周一至周五均可使用",
"rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue": 8000,
"actualValue": 10000,
"type": 1,
"stock": 100,
"beginTime": "2022-01-25T10:09:17",
"endTime": "2037-01-26T12:09:04"
}
新增的秒杀券成功添加到数据库和Redis中


- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
--1.参数列表
--1.1.优惠卷id
local voucherId=ARGV[1]
--1.2.用户id
local userId=ARGV[2]
--2.数据key
--2.1.库存key
local stockKey='seckill:stock:' .. voucherId
--2.2.订单key
local orderKey='seckill:order:' .. voucherId
--3.脚本业务
--3.1.判断库存是否充足get stockKey
if(tonumber(redis.call('get',stockKey))<=0) then
--3.2.库存不足,返回1
return 1
end
--3.2.判断用户是否下单SISMEMBER orderKey userId //SISMEMBER:判断Set集合中是否存在某个元素,存在返回1,不存在放回0
if(redis.call('sismember',orderKey,userId)==1) then
--3.3.存在,说明是重复订单,返回2
return 2
end
--3.4.扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
--3.5.下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0在Java代码中调用seckill.lua脚本
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列,开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能。
- IVoucherOrderService
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
void createVoucherOrder(VoucherOrder voucherOrder);
}- 改造VoucherOrderServiceImpl
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
//伴随着类的加载而执行,且只执行一次
static {
//装载Lua脚本的“容器”对象
SECKILL_SCRIPT = new DefaultRedisScript<>();
//去当前项目的classpath(通常也就是resources文件夹)的根目录下找文件
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
//告诉Spring框架,等Redis执行完这段Lua脚本后,把Redis返回的结果转换成Java里的什么数据类型
SECKILL_SCRIPT.setResultType(Long.class);
}
// 阻塞队列特点:当一个线程尝试从队列中获取元素,没有元素,线程就会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
/**
* 初始化异步处理任务。
*
* @return无返回值
*/
@PostConstruct
public void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
/**
* 循环处理异步任务。
*
* 作用:
* 1.获取队列中的订单信息;
* 2.//take()方法:从阻塞队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有;
* 3.创建订单;
*
* @return无返回值
*/
public void run() {
while(true){
try {
//1.获取队列中的订单信息
//// take()方法:从阻塞队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
VoucherOrder voucherOrder=orderTasks.take();
//2.创建订单
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("处理订单异常",e);
}
}
}
}
/**
* 执行handleVoucherOrder相关业务逻辑。
*
* 作用:
* 1.获取用户;
* 2.创建锁对象;
* 3.获取锁;
* 4.判断是否获取锁成功;
* 5.获取锁失败,返回错误或重试;
*
* @param voucherOrder voucherOrder参数
* @return无返回值
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//获取用户 //子线程不能通过UserHolder获取用户,必须从voucherOrder对象里拿用户ID,因为ThreadLocal是不跨线程的。
Long userId=voucherOrder.getUserId();
//创建锁对象
RLock lock = redissonClient.getLock("lock:order" + userId);
//获取锁
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
//获取锁失败,返回错误或重试
log.error("不允许重复下单");
return ;
}
try {
//获取代理对象
proxy.createVoucherOrder(voucherOrder);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
private IVoucherOrderService proxy;
/**
* 处理优惠券秒杀下单请求。
*
* 作用:
* 1.获取用户;
* 2.执行lua脚本;
* 3.判断结果是否为0;
* 4.不为0,代表没有购买资格;
* 5.为0,有购买资格,把下单信息保存到阻塞队列;
*
* @param voucherId优惠券id
* @return处理结果
*/
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId=UserHolder.getUser().getId();
//1.执行lua脚本
Long result=stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),userId.toString()
);
//2.判断结果是否为0
int r =result.intValue();
if(r!=0){
//2.1.不为0,代表没有购买资格
return Result.fail(r==1?"库存不足":"不能重复下单");
}
//2.2.为0,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder=new VoucherOrder();
//2.3.订单id
long orderId=redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//2.4.用户id
voucherOrder.setUserId(userId);
//2.5.代金卷id
voucherOrder.setVoucherId(voucherId);
//2.6.放入阻塞队列
orderTasks.add(voucherOrder);
//3.获取代理对象
IVoucherOrderService proxy= (IVoucherOrderService) AopContext.currentProxy();
//4.返回订单id
return Result.ok(orderId);
}
/**
* 创建优惠券订单。
*
* 作用:
* 1.一人一单;
* 2.查询订单;
* 3.判断是否村在;
* 4.用户已经购买过了;
* 5.扣减库存;
*
* @param voucherOrder voucherOrder参数
* @return无返回值
*/
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder){
//5.一人一单
Long userId = voucherOrder.getUserId();
//5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
//5.2.判断是否村在
if(count>0){
//用户已经购买过了
log.error("用户已经购买过一次");
return ;
}
//6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1") //set stock=stock-1
.eq("voucher_id", voucherOrder.getVoucherId())
//.eq("stock", voucher.getStock()) //where id=? and stock=?
.gt("stock", 0)
.update();
if(!success){
log.error("库存不足");
return ;
}
//7.创建订单
save(voucherOrder);
}
}- 接口测试 将数据库和Redis优惠劵库存恢复成200
测试一人一单功能

获取用户第一次下单返回的订单id并且不允许重复下单


再来用JMeter做性能测试,结果发现,由于异步线程写入数据库,耗时减少,吞吐量大幅增加,提高了秒杀系统的并发性能!

总结
秒杀业务思路: 先用Redis完成库存余量,一人一单判断,完成抢单业务 再将下单业务放入阻塞队列,利用独立线程异步下单 基于阻塞队列列的异步秒杀存在哪些问题? 内存限制问题:JDK阻塞队列使用的是JVM内存,高并发订单量可能导致内存溢出,队列大小由我们自己指定,可能超出阻塞队列上限 数据安全问题:JVM没有持久化机制,服务器重启或者宕机,阻塞队列任务都会丢失;从阻塞队列拿到任务未处理,此时发生异常,任务也会丢失,就没有机会再次处理了,造成数据不一致