Redis 基础 - 优惠券秒杀《非集群》

参考

Redis基础 - 基本类型及常用命令
Redis基础 - Java客户端
Redis 基础 - 短信验证码登录
Redis 基础 - 用Redis查询商户信息

摘要

  • 用Redis生成保证唯一性的订单id
  • 代码示例1~代码示例8是优惠券秒杀简单代码的修改过程,可以加深印象,还可以巩固基础知识
  • 本文仅限于非集群模式的场景

关于订单id

当商铺发优惠券后,大量用户就会去抢购。抢购后,就会生成订单,并插入到订单表。

当使用数据表自增主键做订单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也有上述五个特性

  • 唯一性
    在Redis的string数据结构,里面是有自增特性的,即有个incr的命令。因为Redis是独立于数据库之外的,不管你数据库有几张表,或你有几个不同的数据库,但Redis她只有一个,这时候当所有人都来访问Redis,incr的自增一定是唯一的。
  • 高可用
    Redis的集群方案、主从方案、哨兵方案都确保了她的高可用。
  • 高性能
    Redis就是以性能著称的,她比数据库的性能好得多。
  • 递增性
    Redis也是采用自增方案,1 2 3 4这样的,所以说她肯定能够保证整体的递增性。
  • 安全性
    如果Redis采用的是1 2 3 4这种递增方案,就与数据库一样有安全性的问题。所以用Redis用incr实现全局唯一id时,不能直接使Redis的自增数字当做id。可以给他拼接一点别的信息,让他的规律性不那么明显。

Redis实现全局ID生成器

为了提高数据库的性能,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的组成部分整理如下:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32(42,9496,7296)个不同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 更新时间

代码示例1:添加秒杀券

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";
}

这样的话,秒杀券添加了,可以去抢购了。

代码示例2:实现简单的秒杀下单不考虑线程安全问题时

可能会用到的数据表
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。这只是两个线程的例子,如果同一时刻有几百个线程同时这么干,就会出现问题了。

多线程安全问题的常见解决方案是加锁

  • 悲观锁
    认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。所以悲观锁的性能不是很好,因为无论你有多少个线程,都得是一个一个的去执行。所以在高并发的场景下并不是很适合。当然,数据库里面那些互斥的锁也是悲观锁。
  • 乐观锁
    认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据做了修改。乐观锁认为这种线程安全问题发生的可能性其实是比较低的,多数情况下是不会发生的,也没有必要上来就加锁。即如果别的线程没有做修改过,则认为是安全的,自己才更新数据。如果已经被其他线程修改说明发生了安全问题,此时可以重试或异常。因此乐观锁性能会比悲观锁好很多。关键点在于我怎么知道在我更新的时候别人有没有做修改,这个判断成为了关键。

悲观锁解决超卖问题

由于过于简单,因此省略。

乐观锁解决超卖问题

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法(这是应用最广泛的)
    比如,在数据表里有id字段和库存字段以及版本号字段,版本号法就是给数据加上一个版本,在多线程并发的时候,基于版本号来判断有没有被修改过,每当数据做一次修改,版本号就会加一,也就是说版本号是不断变化的,每一次修改版本就会变化一次,所以判断数据有没有被修改过,其实就是判断版本号有没有变化就OK了。
    假设有两个线程,线程1查询库存的时候,不仅仅是查库存,她还要把版本号也查出来,比如查到的是1和1,在线程1还没有去减库存之前,此时线程2插入进来了,这时候就出现了并发的问题,线程2也去查询,查到的也是1和1,紧接着又切到了线程1,线程1去扣减库存去了,判断是否大于0,由于线程1查到的库存1大于0,所以要扣减即-1,还要修改版本号即+1,但更新时加了一个where的and条件是version=刚才线程1拿到的version,如果能操作成功,就说明数据表里的库存和版本号还没有人动,依然是1和1,此时可以放心大胆的-1库存和+1版本号,接着线程2也会继续执行,她也一样要去操作库存-1和版本号+1操作,但同时条件也和线程1一样,即线程2已经查询过的version和他现在要执行时的version要一致,如果不一致,这个更新sql就没法执行,这说明线程2之前,已经有其他线程修改过数据,所以线程2就会知道,数据现在不安全了,即有效防止了发生
    线程安全问题的情况。伪sql如下:
    set stock = stock - 1, version = version + 1 where id = 优惠券id and version = 之前查到的version
  • CAS法
    这是对版本号法的简化版。由于每次都是查库存和版本号,而且干完了还要对库存和版本号进行修改,所以可以直接扔掉版本字段,直接用库存本身就可以。比如,不用判断版本号是否发生变化,直接判断库存本身是否发生变化即可。即此时的伪sql是:set stock = stock - 1 where id = 优惠券id and stock = 之前查到的库存数。

代码示例3:乐观锁解决超卖问题

扣减库存时,可以加判断。

// 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个人中完全可以有成功的人,不可能因为有人改过所以全部失败,之所以剩下的人全失败是因为过于小心了,即只要有改动就会认为有安全问题。

代码示例4:本例中的乐观锁弊端问题做出改善

由于库存其本身的特殊性,即只要大于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查询订单表,如果这两个条件作为查询后订单数据若存在的话,就说明这个人已经下过单了。

修改示例5:添加优惠券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个线程一起干进来的话,还是可能会出现一人多单的问题。所以,要加锁。由于这是插入订单的情况,所以不能用刚才那种查询的方式去判断,所以这里要使用悲观锁。

修改示例6:试着给用户加锁,相同用户只能执行一次

@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()) {加载那里就不合适了。应该是把整个函数都锁起来,要包含事务。

修改示例7:试着把锁的位置变化,要保证事务在锁释放之前提交

@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函数。

修改示例8:一人一单的最终代码

@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或跨进程的锁。

你可能感兴趣的:(Redis,Redis优惠券,Redis秒杀,Redis唯一ID,Redis订单)