文章标题:
Redis实战:解锁新颖秒杀之法
文章内容:
目录
前言:
实现全局ID生成器,处理秒杀优惠券(运用乐观锁解决超卖状况),以及应对秒杀场景下一人一单的情况(涉及单机与集群环境中的线程安全问题)
1.全局ID生成器
1.1.思考:
鉴于以往一直把数据库中的id设置为自增长字段(每次自增1),以订单id为例,会出现什么问题呢?
- 每新增一个订单,订单id就自增1,这样id的规律性太过明显,用户能够依据订单id去推测商家的营收状况(获取到商家的数据)
- 要是订单量很大,数据库中的一张表无法容纳这么多数据,需要分表存储,但由于设置的id自增(每张表都从1开始自增),会导致订单id出现重复,在后续售后处理时,凭借订单id查询订单信息会因为id重复而不便于操作
1.2.订单id的特性:
- 订单数量大
- id需唯一
1.3.全局ID生成器的要求:
- 唯一性:保证id独一无二
- 高可用性:确保任何时候都能生成正确的id
- 高性能性:保证生成id的速度足够快
- 递增性:保证id整体呈逐渐递增态势,有利于数据库创建索引从而加快插入速度
- 安全性:规律性不能过于显著
1.4.实现方案:
- UUID:生成16进制后转换为字符串(无序且不自增)
- Redis自增:第1位是符号位,恒为0;接下来31位是时间戳,记录id生成时间;最后的32位是序列号,生成64位二进制后形成long类型数据
- snowflake(雪花算法):第1位是符号位,恒为0;接下来41位是时间戳,记录id生成时间;然后10位是工作进程ID,用于区分不同服务器或进程;最后的12位是序列号,用于同一毫秒内生成不同id,生成64位二进制后形成long类型数据
- 数据库自增:单独用一张表存储生成的id值,其他需要使用id的表通过查询该表获取
1.5.具体实现(Redis自增方案):
为何可行:
- 唯一性:由于Redis独立于数据库之外(不管有几张表或几个数据库),Redis仅有一个,所以其自增的id永远唯一
- 高可用:借助集群、哨兵、主从等方案
- 高性能:Redis基于内存,数据库基于硬盘,因此性能更优
- 递增:Redis自带命令可实现自增
- 安全性:不会直接使用Redis的自增数值(仍存在规律性明显的问题),通过拼接信息来实现
如何实现:采用拼接信息的方式,为提升性能,选用数值类型(long类型),因其占用空间小,便于建立索引
实现步骤:拼接信息,第1位是符号位,恒为0(0表示正,1表示负);接下来31位是时间戳(秒数),记录id生成时间;最后的32位是序列号(Redis自增数),生成64位二进制后形成long类型数据
解释:
时间戳(秒数):利用当前时间减去自行设置的开始时间得到的时间秒数
------------
思考:为何不直接使用当前时间的秒数呢
解释:直接使用当前时间秒数易被猜到规律,规律性明显
序列号:Redis自增数
------------
实现:Redis自增数使用String类型中的increment命令(每次自增1),该命令若Redis中无key会自动创建key并自增(此时值为1),若有key则直接将key中的value自增1,最终返回value值
------------
细节:由于使用Redis命令,最终序列号作为value存入Redis,那么存入Redis的自增数不就是订单数吗?后续若要统计订单数直接查询Redis即可,为方便查询,key需设置有意义的(通过key)
-------------
key的设置:自行设置前缀(因生成id的不只是订单id,需指定对应前缀区分),然后用前缀拼接时间(具体到天),最终形成一个key
------------
思考:加前缀能理解用于区分存入Redis的key,为何还要拼接时间呢?
解释:若序列号都用同一个key,Redis存入有上限,且拼接时间(具体到天)便于统计每天的下单量
实现细节:
思考:最终得到时间戳(秒)long类型,序列号(订单数)long类型,需拼接成全新的long,符号位无需关注(正数为0,负数为1)

步骤:将时间戳左移32位(留出序列号的位置),因左移位时以0填充,再将移位后的时间戳与序列号异或(只要有一个为真就是真,有1就是1),第一位符号位无需关注,时间戳为正数(id一般设为正数),最终形成新的long类型id
解释:这里是二进制计算,二进制只有0/1,有值则为1,无值则为0(异或)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class RedisIdWorker {
@Autowired
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//定义开始时间戳
private static final Long BEGIN_TIME_SECOND = 1740960000L;
//移动位数
private static final Long COUNT_BIT = 32L;
public Long setId(String keyPrefix){
//1.设置时间戳
//当前时间戳
LocalDateTime now = LocalDateTime.now();
long second = now.toEpochSecond(ZoneOffset.UTC);
//最终时间戳
Long time = second - BEGIN_TIME_SECOND;
//2.获取序列号
String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//Redis返回的序列号
long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);
//拼接
return time << COUNT_BIT | increment;
}
}
Redis图效果:

2.秒杀优惠券
2.1.秒杀优惠券的基本实现
思索:在进行优惠券下单之前,需判断两点
- 秒杀是否已开启或已结束
- 库存数量是否充足
步骤:
前端提交优惠券ID
==》后端接收该ID
==》依据优惠券ID查询数据库,获取优惠券信息
==》判断秒杀是否处于开启或结束状态
==》若秒杀未开启或已结束
==》返回错误提示信息
-------
==》若秒杀正在进行中
==》判断库存数量是否充足
==》若库存不足
==》返回错误提示信息
-------
==》若库存充足
==》扣减库存数量
==》创建订单
==》返回订单ID


@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1.根据id查询数据库优惠券信息
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
//2.获取时间
LocalDateTime beginTime = voucher.getBeginTime();
LocalDateTime endTime = voucher.getEndTime();
//3.判断时间
if (beginTime.isAfter(LocalDateTime.now())) {
return Result.fail("秒杀还未开始");
}
if (endTime.isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
//4.获取库存
Integer stock = voucher.getStock();
//库存不足
if(stock < 1){
return Result.fail("库存不足");
}
//库存足
//5.库存减1
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.update();
if (!success){
return Result.fail("库存不足");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//优惠券id
voucherOrder.setVoucherId(voucherId);
//订单id
Long orderId = redisIdWorker.setId("order");
voucherOrder.setId(orderId);
//用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//存入数据库
save(voucherOrder);
return Result.ok(orderId);
}
}
@Component
public class RedisIdWorker {
@Autowired
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//定义开始时间戳
private static final Long BEGIN_TIME_SECOND = 1740960000L;
//移动位数
private static final Long COUNT_BIT = 32L;
public Long setId(String keyPrefix){
//1.设置时间戳
//当前时间戳
LocalDateTime now = LocalDateTime.now();
long second = now.toEpochSecond(ZoneOffset.UTC);
//最终时间戳
Long time = second - BEGIN_TIME_SECOND;
//2.获取序列号
String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//Redis返回的序列号
long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);
//拼接
return time << COUNT_BIT | increment;
}
}
解释:重点需留意秒杀的时间和库存数量的判断
2.2.超卖问题
解释:
- 前提:库存此时为1
示例:线程1先执行查询库存,线程2再执行查询,线程1扣减库存,线程2扣减库存
==》线程1先执行
==》线程1查询库存(1)
==》线程2抢到执行权
==》线程2查询库存(1)
==》线程1再次抢到执行权
==》因库存大于0
==》线程1执行库存扣减操作
==》此时库存(0)
==》线程2执行
==》因之前查询库存结果为1
==》线程2也执行库存扣减操作
==》此时库存(-1)
----------
那么此时 优惠券库存为-1,出现超卖问题


2.3.解决超卖问题的方案
解决方案:
方案一:悲观锁
悲观锁:认为线程安全问题必定会发生,所以在每次操作数据前先获取锁,以保证线程安全,使线程串行执行
- Synchronized,Lock都属于悲观锁
- 优点:简单直接
- 缺点:性能一般
方案二:乐观锁
乐观锁:认为线程安全问题不一定发生,所以不加锁,仅在更新数据时判断有无其他线程修改数据
- 若未修改则认为安全,自行更新数据
- 若已被其他线程修改说明出现安全问题,此时可重试或返回异常
- 优点:性能较好
- 缺点:存在安全率低的问题
解释:悲观锁直接加锁,因加锁其他线程需等待,性能低;乐观锁不加锁,会出现安全问题(概率低)
思考:
- 由于是优惠券库存问题(有数据可判断是否被修改),可直接依据库存判断是否出现数据不一致问题,所以可采用乐观锁
- 若不是库存问题,需通过数据整体变化判断,此时采用乐观锁复杂,需判断数据多,所以采用悲观锁
- 但悲观锁性能一般,如何提高性能:采用分批加锁(分段锁),将数据分成几份(假设分10张表),用户同时抢这10张表,同时10人抢(提高效率),最终思想:每次锁定资源少
总结:若要更新数据可用乐观锁,添加数据用悲观锁
2.4.基于乐观锁来解决超卖问题
版本号法: 设置版本号,每次查询库存时也查询版本号,最后扣减库存时增加判断条件(此时版本号应等于先前查询到的版本号),若不等事务回滚
思想:更新数据前比较版本号是否改变
步骤:
前端提交优惠券ID
==》后端接收该ID
==》依据优惠券ID查询数据库,获取优惠券信息,获取版本号
==》判断秒杀是否处于开启或结束状态
==》若秒杀未开启或已结束
==》返回错误提示信息
-------
==》若秒杀正在进行中
==》判断库存数量是否充足
==》若库存不足
==》返回错误提示信息
-------
==》若库存充足
==》判断版本号是否改变
==》若改变返回错误提示信息
-------
==》若版本号相同
==》扣减库存数量
==》创建订单
==》返回订单ID


CAS法 :直接比较库存,更新数据时增加判断条件(库存是否改变),库存改变不执行更新操作事务回滚
思想:直接利用已有数据判断,根据数据是否改变确定是否更新数据
步骤:
前端提交优惠券ID
==》后端接收该ID
==》依据优惠券ID查询数据库,获取优惠券信息
==》判断秒杀是否处于开启或结束状态
==》若秒杀未开启或已结束
==》返回错误提示信息
-------
==》若秒杀正在进行中
==》判断库存数量是否充足
==》若库存不足
==》返回错误提示信息
-------
==》若库存充足
==》判断库存是否改变
==》若改变返回错误提示信息
-------
==》若库存相同
==》扣减库存数量
==》创建订单
==》返回订单ID
![](https