[Java] Redis实现秒杀
更好的阅读体验:https://www.yuque.com/ahaccmt/dfvafl/kay5ckgtlzxflazc?singleDoc# 《Redis实现秒杀》
优惠券秒杀涉及到多方面内容。
全局唯一ID
我们首先考虑了订单的全局唯一ID设计,防止ID规律被猜出和便于分库分表。
ID由时间戳和序列号两部分构成,足以满足需求。
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 获取时间戳
LocalDateTime now = LocalDateTime.now();
long timestamp = now.toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
// 获取年月日
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long cnt = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timestamp << COUNT_BITS | cnt;
}
}
乐观锁解决超卖问题
之后实现了基础的优惠券秒杀功能,但是容易出现超卖问题,其根源在于程序运行于多线程并发环境下,如下图所示。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:
乐观锁相较悲观锁效率更高,我们只需要在更新库存数据时判断库存是否大于0即可。(下图使用版本号,但成功概率太低)
sql语句如下
<update id="updateStock">
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = #{voucherId} and stock > 0;
</update>
一人一单限制
接着我们实现了一人一单的限制,其关键在于加锁,确保同一时间用户只能有一个线程进入下单逻辑(判断用户是否购买和扣减库存)。
单机解决方案
使用synchronized
关键字,对用户ID加锁,同时缩小锁的粒度,提升执行效率。
我们将下单逻辑集中到了一个方法中,但要注意这种调用方式将会使spring
事务管理失效。
为什么事务会失效?
Spring 事务管理是基于代理对象的:当你调用一个方法时,实际上是调用了代理对象的实现,而不是直接调用类本身的方法。
synchronized
锁定的是当前实例的对象:当你在 synchronized
中调用方法时,调用的是 当前类实例的直接方法。因为方法的调用是直接通过类实例调用的,所以 Spring 的事务代理就不会介入,事务也不会生效。
解决办法:
- 使用 代理对象 来调用方法,而不是直接在类实例上调用。
- 可以通过注入代理对象(
@Autowired
),或者将同步块移到外部来确保事务生效。
分布式解决方案
当服务端运行在分布式系统时,这样的单机加锁方式便失效了。需要利用分布式锁来确保一人一单。
使用Redisson分布式锁来解决一人一单问题。
public void handleVoucherOrder(VoucherOrder order) {
Long userId = order.getUserId();
RLock lock = redissonClient.getLock("order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(order);
} catch (Exception e) {
log.error("秒杀异常", e);
} finally {
lock.unlock();
}
}
秒杀异步优化
原先的程序在秒杀下单过程中,多次与数据库交互,效率低下。用户秒杀成功或失败的标准是能否下单,而更新数据库的操作并不具备很强的时效性,可以在后台慢慢处理。因此我们可以将库存数据提前转入Redis缓存中,这样在判断能否下单时,直接操作高速的缓存即可,无需进行缓慢的数据库操作。
使用阻塞队列
阻塞队列是Java提供的一种数据结构,使用其作为实现异步的消息队列具有以下优缺点:
优点:Java 原生支持,可以直接使用 LinkedBlockingQueue
或 ArrayBlockingQueue
,无需额外引入第三方中间件(如 Kafka、Redis 等)。
缺点:
-
无法应对高并发场景
-
队列有容量限制,高并发秒杀可能导致大量请求阻塞或被丢弃,用户体验不佳。
-
单机
BlockingQueue
性能有限,不能横向扩展,不适用于大规模分布式架构。
-
-
消息丢失风险
- 如果消费者线程意外终止(宕机、OOM),可能导致队列中的未处理请求丢失,而缺乏持久化机制。
-
无法灵活扩展
- 队列容量是固定的,如果需要动态扩容,必须重新调整队列大小或者更换实现方案,相对不够灵活。
✅ 适合小型秒杀,快速搭建,简单易用。
❌ 不适合高并发、大流量场景,存在性能瓶颈、库存同步问题。
使用Redis的stream消息队列
在秒杀lua脚本中加入发送消息的代码
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
单独开启一个线程专门用于处理消息,插入新的订单和更新库存。
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
public void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
String messageQueue = "stream.orders";
@Override
public void run() {
while (true) {
try {
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(messageQueue, 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);
handleVoucherOrder(voucherOrder);
// 发送ACK
stringRedisTemplate.opsForStream().acknowledge(messageQueue, "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(messageQueue, 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);
handleVoucherOrder(voucherOrder);
// 发送ACK
stringRedisTemplate.opsForStream().acknowledge(messageQueue, "g1", record.getId());
} catch (Exception e) {
log.error("处理Pending List异常", e);
}
}
}
/**
* 处理单个订单
* @param order 从队列中取出的订单
*/
public void handleVoucherOrder(VoucherOrder order) {
Long userId = order.getUserId();
RLock lock = redissonClient.getLock("order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(order);
} catch (Exception e) {
log.error("秒杀异常", e);
} finally {
lock.unlock();
}
}
}
压力测试
生成token,并将其写入redis缓存和csv文件中。
public void testToken() {
try (BufferedWriter writer = new BufferedWriter(new FileWriter("C:\\Users\\wy\\Desktop\\token.csv"))) {
// 遍历 tokens 列表,写入每个 token 到文件中
for (int i = 0; i < 2000; i ++) {
String token = UUID.randomUUID().toString(true);
String key = "login:token:";
stringRedisTemplate.opsForHash().put(key + token, "id", String.valueOf(i + 1));
writer.write(token + ",");
writer.newLine();
}
System.out.println("Tokens saved successfully");
} catch (IOException e) {
System.err.println("Error writing to file: " + e.getMessage());
}
}
在JMeter中导入用户token列表
设置header中的验证字段
参考内容
黑马Redis课程
https://www.bilibili.com/video/BV1cr4y1671t