Redis实现优惠券秒杀功能
全局唯一ID
订单表使用数据库自增ID的缺点:
1、ID规律性太明显。
2、受单表数据量限制,需要分库分表时数据库自增ID失效。
全局ID生成器:在分布式系统中生成全局唯一ID的工具,需要满足以下特性:
1、唯一性
2、高可用
3、高性能
4、递增性
5、安全性
例如Redis的string类型的incr命令。
但是为了ID安全性,不能直接使用Redis自增的数值,而是拼接其他信息。
例如,使用Long类型,占8个字节,64bit位,1bit符号位,前31bit使用时间戳,支持69年使用时间,后32位使用序列号,每秒产生2^32个不同ID。
基于Redis实现全局唯一ID例子:
java
@Component
public class RedisIdWorker {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final long BEGIN_TIMESTAMP = 1704067200l;
//序列号位数
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix) {
//生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSeconds = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSeconds - BEGIN_TIMESTAMP;
//生成序列号,每天2^32个ID
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":" + date);
return timestamp << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2024, 1, 1, 0, 0, 0);
long seconds = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(seconds);
}
}
实现优惠券秒杀下单
优惠券分为平价券和特价券,平价券可以任意购买,特价券需要秒杀抢购。这里针对特价券而言。
tb_voucher包含平价券和特价券,tb_seckill_voucher只描述特价券,包含库存,生效时间,失效时间。
秒杀下单需要判断:
1、秒杀是否开始或者是否已经结束
2、库存是否足够,足够才扣减库存并且创建订单
功能实现后,高并发场景下出现了超卖,剩余库存是负数!原因是多个线程先查询库存,后续进行扣减,整个操作不是原子性的。
解决:
1、加悲观锁
比如synchronized,Lock,或者分布式锁。缺点是开销较大。
2、加乐观锁:
版本号法,每次修改时判断和刚刚查询的版本号是否一致,修改后版本号+1。优点是性能好,缺点是有可能出现少买。
判断库存大于0即可,判断条件为where id = ? and stock > 0; 完美解决。
实现一人一单
同一优惠券,一个用户只能下一单。
先判断订单表中是否存在用户id和优惠券id的记录,如果记录数大于0,则返回失败。
缺点,用户还是能下多单,因为多个线程判断成功后可以继续购买,和后续扣减库存,生成订单不是原子性操作。
这只能使用悲观锁,因为乐观锁只能适用修改,但是这里是新增操作。
这里对用户id加锁,但是为了防止对象变化,使用字符串,并且调用intern方法在字符串常量池中取对象。
锁的范围应该是整个事务提交之后,防止事务还没提交,但是锁被释放其他线程又加锁下单。
事务失效,使用this.createVoucherOrder(),需要拿到当前对象的代理对象。
需要添加依赖AspectJ,启动类添加注解暴露代理对象。
分布式锁
之前的锁都是本地锁,考虑分布式情况下,多个机器的锁不共享,需要使用分布式锁。
分布式锁参考博客。
为什么Redisson没有锁自动续期?
这里碰到一个问题,观察分布式锁的ttl时发现没有自动续期,而是过期删除了,这是因为使用Debug模式,因此Redisson没有自动续期。
Redis秒杀优化
原本流程:
1、查询优惠券
2、判断秒杀库存
3、查询订单
4、校验一人一单
5、减库存
6、创建订单
优化:
1、将秒杀库存和订单信息缓存到Redis中
2、保存优惠券id,用户id,订单id到阻塞队列,返回订单id
3、异步读取阻塞队列信息,完成下单
库存使用string类型,订单信息使用set类型,并且使用lua脚本保证原子性。
基于阻塞队列的异步秒杀存在哪些问题?
1、使用的是JVM的阻塞队列,内存有限。
2、如果宕机,内存数据丢失。
解决:使用消息队列。
lua
local voucherId = ARGV[1]
local userId = ARGV[2]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
if (tonumber(redis.call('get', stockKey)) <= 0) then
--库存不足,返回1
return 1
end
if (redis.call('sismember', orderKey, userId) == 1) then
--重复下单,返回2
return 2
end
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
--成功,返回0
return 0
java
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.execute(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
VoucherOrder voucherOrder = orderTasks.take();
handlerVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handlerVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
//由于有扣减库存和写入订单,因此使用事务
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
//一人一单
Long userId = voucherOrder.getUserId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) {
log.error("用户已经购买过一次!");
return ;
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1").eq("voucher_id", voucherOrder.getVoucherId())
.ge("stock", 1).update();
if (!success) {
log.error("库存不足!");
return ;
}
//写入订单
save(voucherOrder);
}
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足!" : "不能重复下单!");
}
//保存信息到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
//使用全局唯一ID生成订单ID,而不是使用数据库自增ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
orderTasks.add(voucherOrder);
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(0);
}
}
}
Redis消息队列
1、基于List实现消息队列。
使用lpush结合rpop;或者rpush结合lpop,为了实现阻塞效果,取出时使用blopo和brpop。
2、基于PubSub实现消息队列。
在Redis2.0引入的消息传递模型。消费者可以订阅多个channel。
subcribe channel
publish channel msg
psubscribe pattern
3、基于stream实现消息队列。
在Redis5.0引入的新数据类型,可以实现功能完善的消息队列,并且可以持久化。
xadd key id field value,发送消息
xread count streams key id
缺点:ID为$表示读取最新消息,但是如果多条消息到达,只会读取最新的那条。
消费者组。
使用stream实现消息队列,实现异步秒杀下单
1、创建stream类型的消息队列,名为stream.orders
2、修改lua脚本,向stream.orders中添加消息,包含优惠券id,用户id,订单id
3、项目启动时,开启线程任务,尝试获取stream.orders小夏,完成下单
创建消息队列命令:
XGROUP CREATE stream.orders g1 0 MKSTREAM
lua脚本
lua
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
if (tonumber(redis.call('get', stockKey)) <= 0) then
--库存不足,返回1
return 1
end
if (redis.call('sismember', orderKey, userId) == 1) then
--重复下单,返回2
return 2
end
--扣库存
redis.call('incrby', stockKey, -1)
--下单
redis.call('sadd', orderKey, userId)
--发送到消息队列
redis.call("xadd", "stream.orders", "*", "userId", userId, "voucherId", voucherId, "id", orderId)
--成功,返回0
return 0
java
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.execute(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
String queueName = "streams.order";
@Override
public void run() {
while (true) {
try {
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
if (list == null || list.isEmpty()) {
continue;
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
handlerVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
if (list == null || list.isEmpty()) {
break;
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
handlerVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception e) {
log.error("处理pending-list异常", e);
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
}
}
// private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
//
// private class VoucherOrderHandler implements Runnable {
//
// @Override
// public void run() {
// while (true) {
// try {
// VoucherOrder voucherOrder = orderTasks.take();
// handlerVoucherOrder(voucherOrder);
// } catch (Exception e) {
// log.error("处理订单异常", e);
// }
// }
// }
// }
private void handlerVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
//由于有扣减库存和写入订单,因此使用事务
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
//一人一单
Long userId = voucherOrder.getUserId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) {
log.error("用户已经购买过一次!");
return ;
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1").eq("voucher_id", voucherOrder.getVoucherId())
.ge("stock", 1).update();
if (!success) {
log.error("库存不足!");
return ;
}
//写入订单
save(voucherOrder);
}
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//使用全局唯一ID生成订单ID,而不是使用数据库自增ID
long orderId = redisIdWorker.nextId("order");
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足!" : "不能重复下单!");
}
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(0);
}
}
}