Redis基础 - 基本类型及常用命令
Redis基础 - Java客户端
Redis 基础 - 短信验证码登录
Redis 基础 - 用Redis查询商户信息
当商铺发优惠券后,大量用户就会去抢购。抢购后,就会生成订单,并插入到订单表。
当使用数据表自增主键做订单id时
id的规律性太明显
比如某用户今天下单后,其订单号是10。明天该用户再下单,其订单号是20。这样的话,该用户就能猜出这个商铺一天卖了10个单子,或许暴露了商家的业绩。
受单表数据量的限制
用户每次购买时都会形成订单,若该网站做到了一定的规模,订单数可能会达到数千万甚至数亿。到时候单张表可能扛不住了,就要分表保存。若每张表都用自增id,多张表的id可能会发生冲突。但订单号是不能重复的。
全局ID生成器
是一种在分布式系统下用来生成全局唯一id的工具,这里的全局是指在同一个业务下,不管你这个分布式系统将来有多少个服务多少个节点,在这个业务下面分成多少张不同的表,最终只要你用id生成器得到的id,她一定是当前业务内唯一的。当然,在不同业务,即便冲突了也没啥关系。由于全局唯一id,经常是用于分布式下的,所以也被称为分布式唯一id。一般要满足下列特性:
全局唯一性。(很多业务都要求必须唯一,比如订单id。)
高可用。(你作为一个id的生成器,你必须确保我任何时候来找你,你都能给我生成这个正确的id,你不能
说我来找你,你却挂了,这样是不行的。)
高性能。(你不仅能正常的生成id,还要保证生产id的速度足够快。)
递增性。(因为这个id是要替代数据库自增id,虽然不一定像数据库那样1 2 3 4 挨着增,但一定要确保整体的一个逐渐变大的特性,这样才有利于给数据库创建索引,提高插入时的速度。)
安全性。(规律性不能太明显。)
Redis也有上述五个特性
为了提高数据库的性能,id会采用数值类型,即Java里的long型,然后直接插入数据库。这是因为数值类型在数据库里占用空间更小,建个索引更方便,速度会更快。由于采用的是long型,所以占用8个字节,即64个比特位,比如如下:
0-00000000 00000000 00000000 00000000 - 00000000 00000000 00000000 00000000
第一个0是符号位,代表id永远是正数。第二段整个时间戳,用来增加id复杂性。之所以是31位,是因为这里要以秒为单位,即定义一个初始的时间。比如说从2022年1月1号开始,算一下当前下单这一刻的时间,得与那个初始的时间的时间差是多少秒,31位表示是21亿多秒,算下来这个秒数大概支持69年,也就是说,69年都用不完31位,那完全够了。第二段是序列号,防止1秒中多下单时发生重复的情况。这里面就是Redis自增的值,从1开始咔咔一顿增,就算时间戳重复了,后面也是不同的。理论上讲,1秒内能同时生成2^32个不同id,所以完全够了。即id的组成部分整理如下:
这样的话,整体来讲肯定是唯一的。不同秒的id都不一样,就算是同秒,后面的序列号也不一样,总之确保了唯一性。而且整体也是单向递增的,因为时间会越来越大,序列号也是越来越大。
另外安全性也能得到保证,因为整体复杂度高了很多,不再是简单的自增了,所以在规律上就不容易被人猜到了。
当然,Redis并不是生成全局唯一id的唯一实现方案,还有很多其他方案。比如UUID、雪花算法。
RedisIdWorker.java
@Component // 定义成spring的bean,方便后续使用
public class RedisIdWorker {
// 开始的时间戳
pviate static final long BEGIN_TIMESTAMP = 1640995200L;// 2022年1月1号 0点0分0秒的秒数
@Resource
private StringRedisTemplate stringRedisTemplate;
// 最终的id是long,所以返回值类型是long。
/* 我们的生成策略是基于Redis的自增长,
而Redis自增长需要有一个key,让对应的值不断增长,不同
业务有不同的key,以keyPrefix来区分不同的业务。
比如订单业务,可以传一个"order"过来。所以这个
参数可以理解成是业务的前缀。*/
public long nextId(String keyPrefix) {
// 1,生成时间戳(需要初始时间,然后用此刻时间减去初始时间)
LocalDateTime now = LocalDateTime.now();// 当前时间
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);// 当前的秒数,参数:时区
long timestamp = newSecond - BEGIN_TIMESTAMP;// id中的时间戳部分
// 2,生成序列号
/* 如果key只是写成《"icr:" + keyPrefix + ":"》是不行的,因为这么写的话意味着整个
订单业务永远采用的是一个key来做自增长,也就是说不管这个业务经历了1年还是10年,
永远都是同一个key。随着我们的业务逐渐的发展,订单越来越多,那么自增的值也就会
越来越大,而Redis单个key的自增长对应的数值是有上限的,上限是2^64,虽然这个值
非常大,但她毕竟是有个上限的,万一超过了上限怎么办,这是其一。其二是在此例子
中真正用来序列号的只有32个比特位,Redis是64位,超过64位就算很难,但超过32位还是有可
能的,那么最后序列号这部分就存不下了,所以不能永远使用同一个key,哪怕是同一个业务。
有一个办法是,在业务前缀的后面拼上时间戳,比如拼上“20220710”,那她代表的是7月10号
这一天,即只要是7月10号下的单,就会以《"icr:" + keyPrefix + ":" + "20220710"》这个key
自增,当到了第二天即7月11号时,再去下单就是新的key了。这么做还有一个好处是,将来要
想统计这一天的订单量,直接看对应日期的key的值就行,所以还有一个统计效果。*/
// 2.1 获取当前日子,精确到天,也可以写成"yyyy:MM:dd",也是方便统计。
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
// 2.2 自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 自增,默认自增1。参数是要自增的key。
// 3,拼接并返回
return timestamp << 32 | count;// 或者直接两个拼接后转long返回
}
}
优惠券(代金券)分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购。比如,特价券打折的多,所以有时间限制和数量限制,此例子就是针对特价券的。
tb_voucher | 优惠券的基本信息,优惠金额、使用规则等。 | |
---|---|---|
id | bigint | 主键 |
shop_id | bigint | 商户id,是哪个商户发放的代金券 |
title | varchar | 代金券标题 |
sub_title | varchar | 副标题 |
rules | varchar | 使用规则 |
pay_value | bigint | 支付金额,单位是分,为了防止出现小数?。例如200代表2元。比如,代金券49块抵50块。那么,实际支付金额是49。 |
actual_value | bigint | 抵扣金额,单位是分。例如200代表2元。这个就是50块了。 |
type | tinyint | 0 普通券【默认】 1 秒杀券 |
status | tinyint | 1 上架【默认】 2 下架 3 过期 |
create_time | datetime | 创建时间 |
update_time | datetime | 更新时间 |
tb_seckill_voucher | 优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息。【特价券专有】 | |
---|---|---|
voucher_id | bigint | 关联的优惠券的id |
stock | int | 库存 |
create_time | datetime | 创建时间 |
begin_time | datetime | 生效时间 |
end_time | datetime | 失效时间 |
update_time | datetime | 更新时间 |
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
@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);
}
然后可以用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";
}
这样的话,秒杀券添加了,可以去抢购了。
不考虑线程安全问题时
tb_voucher_order | 优惠券的订单表 | |
---|---|---|
id | bigint | 主键 |
user_id | bigint | 下单的用户id |
voucher_id | bigint | 购买的代金券id |
pay_type | tinyint | 支付方式 1:余额支付【默认】 2:支付宝 3:微信 |
status | tinyint | 订单状态 1:未支付【默认】 2:已支付 3:已核销 4:已取消 5:退款中 6:已退款 |
create_time | datetime | 创建时间 |
pay_time | datetime | 支付时间 |
use_time | datetime | 核销时间 |
refund_time | datetime | 退款时间 |
update_time | datetime | 更新时间 |
简单业务流程
前端向服务端提交优惠券id,然后根据id查询优惠券信息,判断秒杀是否开始(以及是否以结束),如果还没开始(或已经结束),就返回异常结果,如果已经开始且还没结束,就判断库存是否充足,如果库存不足,返回错误信息,若库存充足,就去扣减库存,然后创建订单,并插入订单表,返回订单id。
VoucherOrderController.java
@RequestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderServie;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderServie.seckillVoucher(voucherId);
}
}
IVoucherOrderService.java
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
VoucherOrderServiceImpl.java
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;// 秒杀券的service,对应着SeckillVoucher,就是那个带库存那表
@Resource
private RedisIdWorker redisIdWorker;// 订单id生成器
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1,根据voucherId查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 秒杀券的id也和优惠券的id值是共有的,所以能这么查
// 2,判断秒杀是否开始
// 判断开始时间是不是大于现在时间
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3,判断秒杀是否已经结束
// 结束时间是不是当前时间的之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4,判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
// 5,扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success) {
// 库存不足
return Result.fail("库存不足");
}
// 6,创建订单
VoucherOrder voucherOrder = new VoucherOrder();// 对应着优惠券订单表tb_voucher_order
// 6.1 订单id
long order_id = redisIdWorker.nextId("order");
voucherOrder.setId(order_id);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 6.4 其他的是取默认值,所以不用设置
// 6.5 订单信息写入数据库
save(voucherOrder);
// 7,返回订单id
return Result.ok(order_id);
}
}
真正的秒杀场景下,是一堆人一起去秒杀,所以在一瞬间的并发量可以达到每秒钟数百甚至上千上万。上面的简单例子在高并发情况下会出现线程安全问题,会出现超卖问题。
《正常的情况》
比如,库存有1个,此时来了线程1和线程2。线程1先执行下单逻辑,线程1查询了库存,此时她查到的结果肯定是1,由于库存大于0,所以线程1会执行扣减的动作,线程1执行扣减后,库存里就变成了0个,线程1的下单逻辑就此走完。接着线程2过来,她查到的库存自然就是0,所以没法下单。
《高并发的情况》
在高并发时,很多线程可能会交叉执行,比如线程1来了后先查询库存,她查到的是1,这个时候线程1本来应该去判断库存够不够,但就在此时,线程2也进来了,在线程1尚未扣减库存之前,线程2进来了,线程2也会去查询库存,查到的也是1,此时轮到了线程1去扣减,线程1判断1大于0,所以执行扣减的动作,于是库存从1变成了0,而到线程2做判断时,因为线程2是在线程1扣减前查询的,所以也是1大于0,所以她也执行了扣减的动作,于是库存从0变成了-1。这只是两个线程的例子,如果同一时刻有几百个线程同时这么干,就会出现问题了。
由于过于简单,因此省略。
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
扣减库存时,可以加判断。
// 5,扣减库存
/*
更新时加条件 : eq("stock", voucher.getStock()),即当前库存等于我查到的库存一样时才更新
*/
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).eq("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
不过用200个线程运行之后,发现库存并没有超卖,但只卖出了20多个,剩下的都提示库存不足,而且失败率很高。虽然超卖安全问题解决了,但却出现了很多失败的情况。之所以出现很多失败的情况是因为乐观锁有一个弊端(存在成功率很低的问题)。
假设库存目前有100个,然后无数个线程一起涌入进来,因为没加锁,所以这些线程几乎并行执行,比如线程1过来查到的库存是100,线程2过来查也是100,比如目前有100个线程,他们都查到了100个,接下来其中一个线程会去执行更新的操作,他会去判断库存是否大于0,大的话就会像上面那样where条件里加stock=100来执行库存减一,然后数据库的库存变成了99,此时其他的线程再想更新库存时,由于数据库的库存已经变成了99,所以他们的where条件是不满足的,所以剩下的99个线程都会认为有人改过了,于是都返回错误,导致失败率大大提高。但从业务层面来考虑,现在还有99个库存,那剩下的99个人中完全可以有成功的人,不可能因为有人改过所以全部失败,之所以剩下的人全失败是因为过于小心了,即只要有改动就会认为有安全问题。
由于库存其本身的特殊性,即只要大于0就行,所以没必要非得判断现代的库存是不是和自己知道的旧库存数量一样,而只要判断大于0就行。
// 5,扣减库存
/*
这里不判断现库存等不等于自己知道的库存,而是去判断库存是否大于0,即把and stock = ?改为
and stock > 0,即eq("stock", voucher.getStock()) 改为 gt("stock", voucher.getStock()),gt是大于的意思。
*/
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
运行后,因为是200个线程,而库存是100个,所以其中只有100个线程才能成功,所以出错率的确是50%,被添加的订单也是100个,基本解决了库存超卖问题。
当然,在其他的情况下,如果不是库存这种东西,而是必须要判断是否一样的那种指标,这时候还可以采用分批加锁的方案,即分段锁的方案,比如说我们把数值类的资源分成几份,比如库存总共是100个,我可以把这100个库存分到10张表,即每个表里面库存量是10,抢的时候可以往多张表里面分别去抢,这样的话相当于同时去10个去查,成功率自然的会提高10倍,解决成功率低的问题。
上面的例子侧重点是优惠券秒杀业务逻辑上,并没有考虑一人一单问题。但实际上,多数情况下优惠券这种东西是一人一单。可以根据优惠券id和用户id查询订单表,如果这两个条件作为查询后订单数据若存在的话,就说明这个人已经下过单了。
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1,根据voucherId查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 秒杀券的id也和优惠券的id值是共有的,所以能这么查
// 2,判断秒杀是否开始
// 判断开始时间是不是大于现在时间
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3,判断秒杀是否已经结束
// 结束时间是不是当前时间的之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4,判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
// 5,一人一单
Long userId = UserHolder.getUser().getId(); // 用户id
// 5.1 根据优惠券id和用户id去查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2 判断是否存在
if (count > 0) {
// 该用户已经购买过了
return Result.fail("该用户已经购买过一次");
}
// 6,更新库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
if (!success) {
// 库存不足
return Result.fail("库存不足");
}
// 7,创建订单
VoucherOrder voucherOrder = new VoucherOrder();// 对应着优惠券订单表tb_voucher_order
// 7.1 订单id
long order_id = redisIdWorker.nextId("order");
voucherOrder.setId(order_id);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.4 其他的是取默认值,所以不用设置
// 7.5 订单信息写入数据库
save(voucherOrder);
// 8,返回订单id
return Result.ok(order_id);
}
虽然上述代码在逻辑上基本没毛病,但运行之后,在高并发情况下,还是可能会出现线程安全问题。
比如用一个人的userid,同时200个线程一起干进来的话,还是可能会出现一人多单的问题。所以,要加锁。由于这是插入订单的情况,所以不能用刚才那种查询的方式去判断,所以这里要使用悲观锁。
@Override
// @Transactional 由于这个方法里只有查的,所以不需要这个。
public Result seckillVoucher(Long voucherId) {
// 1,根据voucherId查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 秒杀券的id也和优惠券的id值是共有的,所以能这么查
// 2,判断秒杀是否开始
// 判断开始时间是不是大于现在时间
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3,判断秒杀是否已经结束
// 结束时间是不是当前时间的之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4,判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
// 为了方便,提取出来。
return createVoucherOrder(voucherId);
}
@Transactional // 更新的部分都整到这里了,所以这里加事务
public Result createVoucherOrder (Long voucherId) {
// 5,一人一单
Long userId = UserHolder.getUser().getId(); // 用户id
/*
因为是一人一单,所以同一个用户来了,才会去判断她的并发安全问题。如果不是同一个用户,
就不需要加锁。可以给用户的id加锁。这样的话就把锁的范围缩小了,没必要在方法上加锁。
即同一个用户就去加锁,不同的用户加不同的锁,也就是说把锁定的资源范围减小了。
另外,就算userId是一样的,但调用toString方法之后,由于生成新的对象,所以值都会变得不一样,
所以调用intern()方法,这样的话用串池的,所以userId值一样的话,对象的值也一样。
*/
synchronized (userId.toString().intern()) {
// 5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2 判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("该用户已经购买过一次");
}
// 6,更新库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
if (!success) {
// 库存不足
return Result.fail("库存不足");
}
// 7,创建订单
VoucherOrder voucherOrder = new VoucherOrder();// 对应着优惠券订单表tb_voucher_order
// 7.1 订单id
long order_id = redisIdWorker.nextId("order");
voucherOrder.setId(order_id);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.4 其他的是取默认值,所以不用设置
// 7.5 订单信息写入数据库
save(voucherOrder);
// 8,返回订单id
return Result.ok(order_id);
}
}
这段代码中synchronized是在方法的内部使用了,如果synchronized加到方法上的话,是对整个方法加的锁,不过现在是在方法内部加锁,所以存在一个问题。比如这里开启事务后,开始执行,执行之后,获取锁,开始查询、减库存、提交订单之后,先释放锁,才会提交事务的。
由于事务是被spring管理的,所以事务的提交是函数执行完以后由spring做的提交。但锁在当synchronized{}即大括号结束之后就已经释放了,那么锁被释放了之后,就意味着其他线程就可以进来了,而此时因为事务尚未提交,如果有其他线程(说的应该也是相同userid)进来去“5.1查询订单”的话,那我们刚刚新增的订单很有可能还没有写入数据库,因为还没提交,所以其他线程查询订单的时候,依然不存在,所以有可能会出现并发安全问题。
那么因此我们这个锁她锁定的范围有点小,她应该是把这整个函数锁起来,这样一来,应该是事务提交之后,我们再去释放锁,所以,synchronized (userId.toString().intern()) {加载那里就不合适了。应该是把整个函数都锁起来,要包含事务。
要保证事务在锁释放之前提交
@Override
// @Transactional 由于这个方法里只有查的,所以不需要这个。
public Result seckillVoucher(Long voucherId) {
// 1,根据voucherId查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 秒杀券的id也和优惠券的id值是共有的,所以能这么查
// 2,判断秒杀是否开始
// 判断开始时间是不是大于现在时间
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3,判断秒杀是否已经结束
// 结束时间是不是当前时间的之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4,判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId(); // 用户id
synchronized (userId.toString().intern()) { // 加在这里的话,就能实现事务提交之后,才释放锁
return createVoucherOrder(voucherId);
}
}
@Transactional // 更新的都跑到这里了,所以这里加事务
public Result createVoucherOrder (Long voucherId) {
// 5,一人一单
// 5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2 判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("该用户已经购买过一次");
}
// 6,更新库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
if (!success) {
// 库存不足
return Result.fail("库存不足");
}
// 7,创建订单
VoucherOrder voucherOrder = new VoucherOrder();// 对应着优惠券订单表tb_voucher_order
// 7.1 订单id
long order_id = redisIdWorker.nextId("order");
voucherOrder.setId(order_id);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.4 其他的是取默认值,所以不用设置
// 7.5 订单信息写入数据库
save(voucherOrder);
// 8,返回订单id
return Result.ok(order_id);
}
经过如此修改,虽然线程安全问题貌似解决了,但却出现了关于事务的问题。
比如这里是对当前的createVoucherOrder函数加了事务,没有给调用她的即外边的seckillVoucher函数加事务,而外面的seckillVoucher函数调用createVoucherOrder函数时是《createVoucherOrder(voucherId)》这么调用的,即这等于《this.createVoucherOrder(voucherId)》,这么调其实是用this调的,而this是当前的VoucherOrderServiceImpl对象,而不是VoucherOrderServiceImpl的代理对象。
而事务要想生效,其实是因为spring对当前的VoucherOrderServiceImpl类做了动态代理,拿到了VoucherOrderServiceImpl的代理对象,用这个代理对象来做事务处理的,而上面的this其实是非代理对象,所以说白了上述代码中《this.createVoucherOrder(voucherId)》是没有事务功能的。
解决方法是,拿到当前对象的代理对象后,再调用createVoucherOrder函数。
@Override
// @Transactional 由于这个方法里只有查的,所以不需要这个。
public Result seckillVoucher(Long voucherId) {
// 1,根据voucherId查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 秒杀券的id也和优惠券的id值是共有的,所以能这么查
// 2,判断秒杀是否开始
// 判断开始时间是不是大于现在时间
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3,判断秒杀是否已经结束
// 结束时间是不是当前时间的之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4,判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId(); // 用户id
synchronized (userId.toString().intern()) { // 家这里的话,事务提交之后,才释放锁
// 拿到当前对象的代理对象(获取跟事务有关的代理对象)
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
/* 当然,这个函数createVoucherOrder只存在实现类里,所以在接口里也要创建。
另外,这么做的话,底层还要依赖aspectj,所以要在pom.xml里添加相关依赖。
还要在启动类暴露这个代理对象*/
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional // 更新的都跑到这里了,所以这里加事务
public Result createVoucherOrder (Long voucherId) {
// 5,一人一单
// 5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2 判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("该用户已经购买过一次");
}
// 6,更新库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", voucher.getStock()) // where id = ? and stock = ?
.update();
if (!success) {
// 库存不足
return Result.fail("库存不足");
}
// 7,创建订单
VoucherOrder voucherOrder = new VoucherOrder();// 对应着优惠券订单表tb_voucher_order
// 7.1 订单id
long order_id = redisIdWorker.nextId("order");
voucherOrder.setId(order_id);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.4 其他的是取默认值,所以不用设置
// 7.5 订单信息写入数据库
save(voucherOrder);
// 8,返回订单id
return Result.ok(order_id);
}
IVoucherOrderService.java
...
Result createVoucherOrder(Long voucherId);
...
pom.xml
<dependency>
<groupId>org.aspectjgroupId>
<artifactId>aspectjweaverartifactId>
dependency>
AsdApplication.java
// 默认是false即不暴露代理对象,不暴露的话是获取不到代理对象的。
@EnableAspectJAutoProxy(exposeProxy = true)
@MpperScan("com.asd.mapper")
@SpringBootApplication
public class AsdApplication {
...
}
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
在集群模式下,或者是在分布式的系统下,有多个JVM的存在,每个JVM内部都有自己的锁,导致每一个锁都可以有一个线程获取,于是就出现了并行运行,那么就可能出现安全问题。所以要想办法让多个JVM只能使用同一把锁,这样的锁不是JDK提供的那些,而是需要我们自己去实现跨JVM或跨进程的锁。