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 da
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值