Redis基础 - 基本类型及常用命令
Redis基础 - Java客户端
Redis 基础 - 短信验证码登录
Redis 基础 - 用Redis查询商户信息
Redis 基础 - 优惠券秒杀《非集群》
Redis 基础 - 优惠券秒杀《分布式锁(初级)》
Redis 基础 - 优惠券秒杀《分布式锁(使用Redisson)》
前端发起请求,到达Nginx,Nginx会把请求负载均衡到Tomcat,在Tomcat内部的业务流程是,根据优惠券id查询优惠券->判断库存->没问题就去查订单(为了一人一单)->没问题就去减库存并创建订单。这些代码可以看出业务时串行执行的,所以整个业务的耗时就是每一步业务的耗时之和,而这里面中查询优惠券、查询订单、减库存、创建订单都会去操作数据库,而数据库的并发能力本身就是比较差的,更何况减库存、创建订单都是对数据库的写操作,另外为了避免安全问题,这里还加了分布式锁,所以整个业务的性能可想而知,整个业务的耗时也变得比较长,所以并发能力也会变得更低。
比如可以把这个业务分成两部分,第一部分是对秒杀资格的判断,也就是判断秒杀库存、校验一人一单,这部分的耗时其实比较短;第二部分是减库存、创建订单等,因为他们是对数据库的写操作,所以耗时较久。即原来是一个人都做,而现在是要把这两部分交给两个人去做,即交给两个线程。即请求到来后,主线程要做的是判断用户的秒杀资格,然后如果她有购买资格,可以开启独立的线程来处理耗时较久的减库存、创建订单之类的。这样的话,这个业务的效率就会大大提升。
当然,为了进一步提高业务的性能,除了把他分离成两块儿之外,我们也还要尽可能去提高秒杀资格判断这一部分的效率,因为这一部分要判断,依然要去查数据库,所以他的性能依然会受到数据库的影响,所以相比于数据库的性能Redis的性能更好,因此可以使用Redis,把优惠券信息、订单信息缓存在Redis中。即把秒杀资格的判断用Redis做。
所以请求来后,先判断库存是否充足(可以用简单的string类型),如果不足,就返回错误,如果充足,就去判断用户是否下单(可以用set),如果已经下单,就范湖错误,如果没下单,就扣减库存,并把userid存入到当前优惠券的set集合,然后再返回。但为了保证这些操作的原子性,可以使用lua。
所以业务修改后大概如下,请求到来后,执行Lua脚本,判断是否有资格下单,如果有资格,就把优惠券id、用户id、订单id存入到阻塞队列中,即谁购买了什么东西,还有你的订单编号是什么,保存下来后,方便将来的异步线程去执行她来完成真正的下单。当然,存到队列后,就把订单id返回到前端。
VoucherController.java
@RequestController
@RequestMapping("/voucher")
public class VoucherController {
@Resource
private IVoucherService voucherService;
// 新增秒杀券 Voucher里面还包含了秒杀券核心的那几个字段,比如库存、生效时间、失效时间等。
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
}
VoucherServiceImpl.java
@Resource
private StringRedisTemplate stringRedisTemplate;
@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中(key是优惠券id,值是库存数)
stringRedisTemplate.opsForValue().set("seckill:stock:" + voucher.getId(), voucher.getStock().toString());
}
然后可以用postman去添加,如下:
{
"shopId" : 1,
"title" : "100元代金券",
"subTitle" : "周一至周五均可使用",
"rules" : "全场通用\\n无需预约\\n可无限叠加\\n不兑现、不找零\\n仅限堂食",
"payValue" : 8000,
"actualValue" : 10000,
"type" : 1,
"stock" : 100,
"beginTime" : "2022-01-26T10:09:17";
"endTime" : "2022-01-26T24:09:04";
}
这样的话,秒杀券添加了,可以去抢购了。
/resouces/seckill.lua
-- 1,参数列表
-- 1.1 优惠券id(因为要从Redis查库存)
local voucherId = ARGV[1] -- 不用KEYS是因为,这个不是key,而是要拼接才能用的
-- 1.2 用户id(因为要在set集合中查询是否存在)
local userId = ARGV[2]
-- 2,数据key
-- 2.1 库存key
local stockKey = "seckill:stock:" .. voucherId -- lua中字符串拼接是两个点
-- 2.2 订单key(set的key,value中保存购买这个优惠券的所有用户id)
local orderKey = "seckill:order:" .. voucherId
-- 3,脚本业务
-- 3.1 判断库存是否充足(tonumber是把字符串转成数字,不然没法和数字比较大小)
if (tonumber(redis.call("get", stockKey)) <= 0) then
-- 3.2 库存不足,返回1
return 1
end
-- 3.2 判断用户是否下单(判断某个元素是不是set集合的成员可以用sismember命令,返回值是1或0)
if (redis.call("sismember", orderKey, userId) == 1) then
-- 3.3 存在说明是重复下单,返回2
return 2
end
-- 能执行到这里,就说明库存够,而且没下过单
-- 3.4 扣库存
redis.call("incrby", stockKey, -1)
-- 3.5 下单(把用户保存到set)
redis.call("sadd", orderKey, userId)
return 0
VoucherOrderServiceImpl.java
@Resource
prviate ISeckillVoucherService iSeckillVoucherService;
@Resource
prviate RedisIdWorker redisIdWorker;
@Resource
prviate StringRedisTemplate stringRedisTemplate;
// 注入RedissonClient
@Resource
private RedissonClient redissonClient;
private static final DefultRedisScript<Long> SECKILL_SCRIPT;
static {
// 随着类的加载而执行,并且只会执行一次,因为这玩意(seckill.lua)加载一次可以,没必要每次都加载
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);// 设置返回值为long
}
/* BlockingQueue是阻塞队列,元素是VoucherOrder。实现类很多,用最简单的ArrayBlockingQueue,并指定
队列初始化的大小,这里给1024 * 1024,太大了也不好。*/
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 由于要开独立线程,所以需要线程池和任务
private static final ExcutorService SECKILL_ORDER_EXCUTOR = Excutors.newSingleThreadExcutor();// 线程池
// 任务
/*
什么时候执行这个任务呢,肯定是用户秒杀抢购之前开始,因为用户一旦开始秒杀,他就会向队列里添加
新的订单,那我们任务就要去队列取出订单信息,所以他必须在队列之前执行。事实上,这个项目一启动,
用户随时可能来抢购,所以应该在这个类初始化之后赶紧的执行这个任务。可以通过spring提供的注解来做:
@PostConstruct,这个注解是在当前类初始化完毕后,去执行init方法。
*/
@PostConstruct
private void init() {
// 类初始化完毕后,执行任务
SECKILL_ORDER_EXCUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while(true) {
// 1,获取队列中的订单信息
// 获取和删除该队列的第一个元素,如果有需要则等待直到有元素可用为止
VoucherOrder voucherOrder = orderTasks.take();// 由于没有元素就卡在这里,有元素才执行,所以不用担心因为死循环而对CPU带来负担
// 2,创建订单
handleVoucherOrder(voucherOrder);
}
}
}
// 异步处理创建订单干入数据库
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 1,获取用户(因为是其他线程异步处理,所以不能从UserHolder取)
Long userId = voucherOrder.getUserId();
// 2,锁对象
/*
从理论上讲,这里不加锁也OK,因为已经在Redis里做了并发判断了,这里再加一次锁,其实
就是做一个兜底以防万一,万一Redis出了问题没判断成功呢,虽然这种可能性几乎没有,但是
该做的判断还是要做一下
*/
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 3,获取锁
boolean isLock = lock.tryLock();
// 4,判断是否获取锁成功
if (!isLock) {
// 若不成功,记录日志就行,因为是异步执行,没必要返回给前端
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
IVoucherOrderService proxy;// 这里定义,子线程可以用。
@Override
// @Transactional 由于这个方法里只有查的,所以不需要这个。
public Result seckillVoucher(Long voucherId) {
// 获取用户
Long userId = UserHolder.getUser().getId(); // 用户id
// 1,执行lua脚本,结果告诉我们有没有购买的资格
Long result = stringRedisTemplate.excute(
SECKILL_SCRIPT,
Collections.emptyList(),// 传空集合,因为这里的Lua中不需要keys
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();// 对应着优惠券订单表tb_voucher_order
// 2.3 订单id
long order_id = redisIdWorker.nextId("order");
voucherOrder.setId(order_id);
// 2.4 用户id
voucherOrder.setUserId(userId);
// 2.5 代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6 其他的是取默认值,所以不用设置
// 2.7 放入阻塞队列
/*
阻塞队列的特点:当一个线程尝试从这个队列里获取元素的时候,如果没有元素,那么这个线程
就会被阻塞,直到队列中有元素,她才会被唤醒,并且获取元素。比如,这个例子中,订单不可能
一直会有,只有有人下单才会有,没人下单就没有,所以在这里用阻塞队列刚刚好。
*/
orderTasks.add(voucherOrder);
// 2.8 获取代理对象。拿到当前对象的代理对象(获取跟事务有关的代理对象)
// 因为在子线程里是获取不到的,所以在主线程获取这个,在子线程直接使用她
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 3,返回订单id
return Result.ok(orderId);
}
@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) {
// 用户已经购买过了,其实不容易出现这个情况,但以防万一记个日志
return;
}
// 6,更新库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
if (!success) {
// 库存不足。由于Redis做了判断,所以不太可能出现,但为了以防万一,可以加日志
return;
}
// 7.5 订单信息写入数据库
save(voucherOrder);
}
把同步下单改为异步下单。同步下单是来了以后判断你有没有资格,有资格我就立即去下单,因为下单、扣库存等等这些业务要加各种各样的事务或者是锁,导致了这一系列的业务全部执行完,她的耗时就会非常的久,最低耗时可能达到100到200多毫秒,甚至平均耗时达到了400多毫秒。而现在的思路是,尽可能去简化业务,然后把业务分成两部分,一部分是对于抢购资格的判断,如果发现有资格,就立即结束就告诉你下单成功,至于耗时较久的下单部分,我们可以异步去完成。
1)内存限制问题
这种模式虽然提升了秒杀业务的性能,但是她也存在一些问题。关键是这里使用的是JDK里面的阻塞队列,而这个阻塞队列她使用了JVM的内存,如果说不加以限制在高并发情况下可能会有无数个订单对象需要去创建并且放到阻塞队列里,可能会导致将来内存溢出。所以在创建阻塞队列时,我们设置了队列的长度,有个上限,那如果这个队列内东西存满了呢,再有新的订单需要往里面塞,就塞不进去了。
2)数据安全问题
现在是基于内存保存了订单信息,如果服务突然宕机了,那内存里的所有订单信息都会丢失,用户已经完成下单了,你告诉人家成功了让人家付款,结果后台没有相关订单数据,这样一来就出现了数据不一致的问题。还有一种情况是,比如我们现在有一个线程从队列里取出了一个下单的任务要去执行,可惜就在此时,发生了严重的事故,比如出现异常之类的,这样一来,这个任务没有被执行,而任务一旦取出来队列里也就没有了,也就是说以后再也不会执行了,导致了任务的丢失,那再一次出现了不一致的情况。