当前位置: 首页 > article >正文

[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 原生支持,可以直接使用 LinkedBlockingQueueArrayBlockingQueue,无需额外引入第三方中间件(如 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


http://www.kler.cn/a/558669.html

相关文章:

  • Grok 3 实际体验效果超越 GPT-4?深度解析与用户反馈
  • stm32mp采用spi接口扩展can
  • 蓝桥备赛(三)- 条件判断与循环(上)
  • 【Arxiv 大模型最新进展】LEARNING HOW HARD TO THINK: 精准思考,智能分配算力(★AI最前线★)
  • 《深入探索Vben框架:使用经验与心得分享》
  • 数仓搭建实操(传统数仓oracle):DWD数据明细层
  • MySQL数据库——索引结构之B+树
  • MySQL要点总结二
  • centos9之ESXi环境下安装
  • OpenAI 周活用户破 4 亿,GPT-4.5 或下周发布,微软加紧扩容服务器
  • 智慧废品回收小程序php+uniapp
  • SMU2025-4
  • 计算机组成与接口5
  • 前端实现socket 中断重连
  • J4打卡—— ResNet 和 DenseNet结合实现鸟类分类
  • 解决phpstudy无法启动MySQL服务
  • SkyWalking集成Kafka实现日志异步采集经验总结
  • 【行业解决方案篇十八】【DeepSeek航空航天:故障诊断专家系统 】
  • BFS(广度优先搜索)的理解与代码实现
  • AI知识架构之AI大模型