突破限制:引入Redis机制

当前系统V1.3 已经具备的功能:

  • 异步处理:用户的点击会立刻得到响应。
  • 流量控制:保护系统不会因过多线程而崩溃
  • 原子化SQL:数据库操作精准无误
  • 内存标记:售罄后能快速拒绝请求。

无法回避的“物理上限”:

  • 用户体验的断崖式下跌:服务器可能在第一秒就收到了数万甚至数十万的 HTTP 请求。而应用内置的 Tomcat 服务器线程池(比如200个)会瞬间被打满。后续的所有请求,都会在操作系统的 TCP 连接队列中排队,最终大量超时。
    • 99% 的用户刷新页面后,看到的是一个永远在“转圈圈”的加载动画,或是冰冷的 “503 Service Unavailable” 错误。
  • 数据库是最终的性能瓶颈:数据库的磁盘I/O、网络带宽、以及自身的处理能力成为了整个系统性能的天花板。
    • 数据库通常是多个业务的共享资源。秒杀业务对数据库的极限压榨,会导致整个网站的其他核心功能全部瘫痪。普通用户无法登录、无法浏览其他商品、无法对购物车里的其他商品下单。为了一个秒杀活动,导致整个电商平台的交易系统停摆,这是任何公司都无法接受的巨大损失。
  • 应用服务器是“单点故障”:应用运行在一个实例上,如果这个应用因为任何原因,比如JVM崩溃或服务器宕机,挂掉,那么整个秒杀服务就会彻底中断。
    • 整个秒杀服务彻底消失,恢复时间未知。
  • 无法水平扩展:所有基于Java内存的并发控制,在多实例部署时都会失效。
    • 暴露了架构的僵化和脆弱

Redis能解决什么问题?

  • 解决了数据库雪崩和用户体验差的问题
    • 把高频的库存读写、用户资格判断,从毫秒级的、基于磁盘的MYSQL,转移到了微秒级的、基于内存的Redis。
    • 99%的读写流量由Redis抗住,每秒可以处理数万甚至数万次请求。
    • 数据库只负责收尾工作,只有极少数成功抢到资格的用户,它们的订单信息才会异步的、平稳的写入数据库中。
  • 解决了单点故障和无法水平扩展的问题
    • 通过将所有需要共享的状态统一放在Redis中,本身的Spring Boot应用本身变成了“无状态”的。

环境准备与集成

在 Spring Boot 项目中成功引入并连接到 Redis

  1. 安装并运行Redis
  • 在电脑上,使用docker在后台启动一个名为seckill-redis的Redis容器,并将其6379端口映射到电脑的6379端口。
    1
    docker run -d --name seckill-redis -p 6379:6379 redis
  1. 添加Maven依赖
  • 在pom.xml文件中,添加新的依赖
    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  • 保存并让IDE重新加载依赖
  1. 配置application.properties
  • 添加 Redis 的连接信息
    1
    2
    3
    # ================== Redis Configuration ==================
    spring.redis.host=localhost
    spring.redis.port=6379
  1. 验证连接
  • 可以创建一个简单的测试类来验证应用启动时能否成功连接到Redis
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Component
    public class RedisConnectionTester implements CommandLineRunner {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public void run(String... args) throws Exception {
    try {
    String result = redisTemplate.getConnectionFactory().getConnection().ping();
    System.out.println("=========================================");
    System.out.println("Successfully connected to Redis. PING response: " + result);
    System.out.println("=========================================");
    } catch (Exception e) {
    System.err.println("=========================================");
    System.err.println("Failed to connect to Redis: " + e.getMessage());
    System.err.println("=========================================");
    }
    }
    }
  • 启动 Spring Boot 应用。在控制台看到了 Successfully connected to Redis 的信息,表示第一阶段就成功。

数据预热与缓存“读”操作

将查询库存的流量从 MySQL 转移到 Redis。

  1. 创建数据预热Service
  • 在秒杀开始之前,把数据从MYSQL加载到Redis。用启动时加载器来模拟。
  • 创建RedisPreheatService.java
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    @Service
    public class RedisPreheatService implements CommandLineRunner{
    public static final String STOCK_KEY = "seckill:stock:";
    public static final String PRODUCT_KEY = "seckill:product:";
    public static final String USER_SET_KEY = "seckill:users:";

    @Autowired
    private ProductRepository productRepository; // 假设你已注入

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // 应用启动后自动执行
    @Override
    public void run(String... args) throws Exception {
    // 假设我们秒杀的商品ID是 1
    long productId = 1L;
    Product product = productRepository.findById(productId).orElse(null);

    if (product != null) {
    // 1. 清理旧数据(为了可重复测试)
    redisTemplate.delete(STOCK_KEY + productId);
    redisTemplate.delete(USER_SET_KEY + productId);

    // 2. 加载库存到 Redis String
    redisTemplate.opsForValue().set(STOCK_KEY + productId, product.getStock());

    System.out.println("=========================================");
    System.out.println("Product " + productId + " stock preheated to Redis: " + product.getStock());
    System.out.println("=========================================");
    }
    }
    }
  1. 改造SeckillService的checkStock方法
  • 直接从Redis读数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 在 SeckillService.java 中
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public Integer checkStock(Long productId) {
    String stockKey = RedisPreheatService.STOCK_KEY + productId;
    Object stockObj = redisTemplate.opsForValue().get(stockKey);
    return stockObj != null ? Integer.parseInt(stockObj.toString()) : -1;
    }

核心逻辑迁移(Redis + Lua脚本)

将最关键的“判断资格&扣减库存”操作,从Java层的锁+数据库,迁移到Redis的原子化Lua脚本。

  1. 创建Lua脚本文件
  • 在 src/main/resources/ 目录下,创建一个新文件夹 scripts。
  • 在 scripts 文件夹里,创建一个新文件 seckill.lua
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    -- seckill.lua
    -- KEYS[1]: 库存的 key (e.g., seckill:stock:1)
    -- KEYS[2]: 已购买用户集合的 key (e.g., seckill:users:1)
    -- ARGV[1]: 当前请求的用户 ID

    -- 1. 判断用户是否重复购买
    if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
    return 2 -- 2 代表重复购买
    end

    -- 2. 获取库存
    local stock = tonumber(redis.call('get', KEYS[1]))
    if stock <= 0 then
    return 1 -- 1 代表库存不足
    end

    -- 3. 扣减库存
    redis.call('decr', KEYS[1])

    -- 4. 记录购买用户
    redis.call('sadd', KEYS[2], ARGV[1])

    return 0 -- 0 代表秒杀成功
  1. 配置并加载Lua脚本
  • 创建一个RedisConfig.java文件。用于管理与Redis相关的Bean。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    @Configuration
    public class RedisConfig {

    /**
    * 【新增】配置并创建 RedisTemplate Bean
    * @param connectionFactory Spring Boot 自动配置好的连接工厂
    * @return RedisTemplate 实例
    */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    // 创建 RedisTemplate 对象
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    // 设置连接工厂
    template.setConnectionFactory(connectionFactory);

    // 创建 JSON 序列化工具
    GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();

    // 设置 Key 的序列化方式为 String
    template.setKeySerializer(new StringRedisSerializer());
    template.setHashKeySerializer(new StringRedisSerializer());

    // 设置 Value 的序列化方式为 JSON
    template.setValueSerializer(jsonSerializer);
    template.setHashValueSerializer(jsonSerializer);

    // 使配置生效
    template.afterPropertiesSet();

    return template;
    }

    @Bean
    public DefaultRedisScript<Long> seckillScript() {
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/seckill.lua")));
    redisScript.setResultType(Long.class);
    return redisScript;
    }
    }
  1. 重构SeckillService的核心秒杀逻辑
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // 在 SeckillService.java 中
    @Autowired
    private DefaultRedisScript<Long> seckillScript;

    // 我们需要一个内存队列来存放成功秒杀的订单信息
    private final BlockingQueue<SeckillOrder> orderQueue = new LinkedBlockingQueue<>(1000);

    // 改造异步执行的后台任务
    private void executeSeckill(Long productId, Long userId) {
    List<String> keys = Arrays.asList(
    RedisPreheatService.STOCK_KEY + productId,
    RedisPreheatService.USER_SET_KEY + productId
    );

    // 执行 Lua 脚本
    Long result = redisTemplate.execute(seckillScript, keys, userId.toString());

    if (result == 0) {
    log.info("用户 {} 秒杀成功!", userId);
    // 秒杀成功,生成订单信息并放入内存队列
    // 此时订单尚未写入数据库
    Product product = ... // 可以从缓存或数据库获取商品信息
    SeckillOrder order = new SeckillOrder();
    order.setProductId(productId);
    order.setUserId(userId);
    order.setOrderPrice(product.getPrice());
    // 将订单放入队列
    try {
    orderQueue.put(order);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    } else if (result == 1) {
    log.warn("用户 {} 秒杀失败:库存不足", userId);
    } else if (result == 2) {
    log.warn("用户 {} 秒杀失败:重复下单", userId);
    } else {
    log.error("用户 {} 秒杀异常", userId);
    }
    }

异步持久化

创建订单消费者Service

  • 新建一个OrderConsumeService.java
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    @Service
    public class OrderConsumerService {

    // ... 其他注入的属性 ...

    // 应用启动后,开启一个后台线程
    @PostConstruct
    private void startConsumer() {
    new Thread(() -> {
    while (true) {
    try {
    SeckillOrder order = seckillService.getOrderQueue().take();
    // 2. 循环体内部现在只调用这个新的、带事务的方法
    createOrderInDb(order);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    log.error("订单消费者线程被中断", e);
    break;
    } catch (Exception e) {
    // 捕获所有其他可能的异常,防止线程意外终止
    log.error("处理订单时发生未知异常", e);
    }
    }
    }).start();
    }

    /**
    * 3. 【新增】一个公开的、带事务注解的方法,专门用于数据库操作
    * @param order 从队列中取出的订单信息
    */
    @Transactional
    public void createOrderInDb(SeckillOrder order) {
    log.info("正在创建订单并扣减MySQL库存: {}", order);

    // 将所有数据库操作都放在这个方法里
    orderRepository.save(order);

    int result = productRepository.deductStock(order.getProductId());
    if (result == 0) {
    // 这是一个补偿逻辑,理论上在Redis阶段已经保证了库存充足
    // 但为了数据最终一致性,如果MySQL库存扣减失败,应抛出异常让事务回滚
    throw new RuntimeException("MySQL a's stock deduction failed for order: " + order);
    }

    log.info("数据库订单创建成功");
    }
    }
  • 在 SeckillService 中为 orderQueue 提供一个 getter 方法。

JMeter压测结果分析与改进

结果分析

  • 结果:日志显示:处理订单时发生未知异常;数据库信息显示:订单正常创建,但是库存数没有减少;存在TransactionRequiredException报错。
  • 分析:
    • 订单正常创建:说明 orderRepository.save(order) 这行代码执行成功了,并且它的结果被提交到了数据库。
    • 库存数没有减少:说明 productRepository.deductStock(…) 这行代码没有成功,或者它的结果被回滚了。
    • 存在TransactionRequiredException报错:deductStock() 在执行时,没有找到一个正在运行的事务。
    • 即,orderRepository.save() 在一个事务里成功了(或者在没有事务的情况下自动提交了),而紧接着的 deductStock() 却发现自己不在任何事务里。但是这两个方法在同一个被@Transactional注解的方法里。所以真正的原因应该是,方法上的@Transactional注解没有生效。因为这个方法是通过this关键字进行的方法自调用,无法触发AOP代理。当startConsumer方法在 while 循环里调用 createOrderInDb(order) 时,它实际上是在调用 this.createOrderInDb(order),绕过了AOP代理,所以无人发现@Transactional注解,事务没有被开启。

改进

  • 注入服务自身,通过代理对象来调用方法。

  • 修改OrderConsumerService.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    @Service
    public class OrderConsumerService {

    // ... 其他注入的属性 ...
    @Autowired
    private SeckillService seckillService;

    // 2. 注入自己(代理对象)
    // 使用 @Lazy 是为了解决循环依赖的潜在问题
    @Autowired
    @Lazy
    private OrderConsumerService self;

    // 应用启动后,开启一个后台线程
    @PostConstruct
    private void startConsumer() {
    new Thread(() -> {
    while (true) {
    try {
    SeckillOrder order = seckillService.getOrderQueue().take();
    // 3. 【关键改动】通过 self 代理对象来调用事务方法
    self.createOrderInDb(order);
    } catch (InterruptedException e) {
    // ...
    } catch (Exception e) {
    // ...
    }
    }
    }).start();
    }

    /**
    * 这个方法保持不变,但现在它能被正确地代理了
    */
    @Transactional
    public void createOrderInDb(SeckillOrder order) {
    // ... 之前的数据库操作逻辑完全不变 ...
    log.info("正在创建订单并扣减MySQL库存: {}", order);
    orderRepository.save(order);
    int result = productRepository.deductStock(order.getProductId());
    if (result == 0) {
    throw new RuntimeException("MySQL stock deduction failed for order: " + order);
    }
    log.info("数据库订单创建成功");
    }
    }
  • @Autowired private OrderConsumerService self; 注入的 self 变量,不是 this 对象,而是 Spring 创建的、包含了事务处理逻辑的代理对象。

  • 当调用 self.createOrderInDb(order) 时,请求就从startConsumer发到了AOP代理那里。

  • AOP代理会正常地开启事务,然后再让真实对象去执行数据库操作。这样,@Transactional 就重新恢复了它的作用。

  • 结果:数据库信息显示正常,库存正确减少,订单正确建立。

学学八股

Redis

  • Redis是一个开源的、基于内存的、key-value结构的高性能数据库。
    • 基于内存:是Redis高性能的根本原因。所有数据都存储在内存中,读写速度极快,远超基于磁盘的数据库。
    • key-value:数据存储方式非常简单,像一个巨大的HashMap,通过一个唯一的Key来存取一个Value。
    • 不仅仅是缓存:除了被用于缓存外,也被广泛运用于数据库、消息队列等。
  • 核心原理(为什么快)
    • 纯内存操作:所有的操作都在内存中完成,完全避免了磁盘I/O这个最耗时的环节。
    • 单线程模型:Redis的核心网络模型和命令处理是由一个单线程来完成的。无线程切换开销、无锁竞争、I/O多路复用。
  • Redis的原子性与Lua脚本
    • Redis的单个命令是原子性的,但是多个命令组合在一起,就不是原子性的。
    • 但Redis允许将一段Lua脚本作为一个整体发送给服务器执行,Redis会保证这个脚本在执行期间不会被任何其他命令打断,从而实现了多个命令的原子性组合。
  • Redis的持久化机制
    • RDB:在指定的时间间隔内,将内存中的数据快照完整的写入到磁盘上的一个二进制文件中。恢复速度快,文件紧凑。但如果Redis在两次快照之间崩溃,会损失一部分数据。
    • AOF:将每一条接受到的写命令,以追加的方式写入到一个日志文件中,恢复时,重新执行一遍文件中的所有写命令。数据的安全性更高(最多只丢失1秒的数据),单文件体积大,恢复速度相对较慢。
  • Redis的缓存经典问题
    • 缓存穿透:查询一个数据库中根本不存在的数据,缓存中自然也没有,导致每次请求都直接打到数据库上,失去了缓存的意义。
      • 缓存空对象:如果数据库查询结果为空,依然在Redis中缓存一个特殊的空值,并设置一个较短的过期时间。
      • 布隆过滤器:在Redis前再加一道屏障,用布隆过滤器快速判断请求的数据是否存在。
    • 缓存击穿:一个热点Key在某个瞬间突然失效,导致海量的并发请求同时涌向这个Key,并全部穿透到数据库,导致数据库瞬时压力过大。
      • 互斥锁:当缓存失效时,第一个查询请求获取一个互斥锁,然后去加载数据并回设缓存。其他线程则等待锁释放后,直接从缓存中获取数据。
      • 热点数据永不过期:对极热点的数据设置逻辑过期,由后台线程异步更新。
    • 缓存雪崩:大量的key在同一时间集中失效,导致瞬时大量的请求都穿透到数据库。
      • 随机化过期时间:在基础过期时间上,增加一个随机值,避免集中失效。
      • 高可用架构:通过Redis集群、限流降级等操作,保证即使缓存出现问题,数据库也不会被完全冲垮。

本站由 Xylumina 使用 Stellar 1.30.0 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

本"页面"访问 次 | 总访问 次 | 总访客