Day78~80(4~6)-F:\code\hm-dianping\hm-dianping-Redis秒杀

黑马点评

优惠券秒杀

全局唯一ID

image-20260111114143112

image-20260111132550290

设计分表查询

image-20260111133906021

知识点

  1. Runnable任务
  2. CountDownLatch
  3. RedisIdWorker
1.Runnable任务

Runnable 是“活儿”:它只描述逻辑。

2.CountDownLatch

它的逻辑是:初始设定一个数字,每完成一个任务就减 1,只有当数字变成 0 时,被阻塞的门才会打开。

3.RedisIdWorker
package com.hmdp.utils;

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 {

    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1767225600L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPreFix){
        //1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        //2.生成序列号
        //2.1获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPreFix + ":" + date);
        //3.拼接并返回
        return timestamp<<COUNT_BITS|count;
    }

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2026, 1, 1, 0, 0, 0);
        long second = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println("second="+second);
    }
}

image-20260111150533647

image-20260111155847036

image-20260111161117830

超卖问题

image-20260111173007465

image-20260111173251342

image-20260111173650010

image-20260111173814933

CAS法可能导致aba问题

知识点

  1. mysql默认加了行级锁
1.mysql默认加了行级锁

MySQL 的默认存储引擎是 InnoDB,它与早期的 MyISAM 引擎最大的区别之一就是支持行级锁(Row-Level Locking)。
以下是行级锁触发的具体逻辑:

  1. 什么时候会自动加行级锁?
    在 InnoDB 中,你不需要手动写类似 LOCK ROW 的命令,系统会在以下场景自动处理:
    • 增删改操作(DML):当你执行 INSERT、UPDATE 或 DELETE 时,MySQL 会自动给涉及到的行加上排他锁(X锁)。 在你的秒杀业务中,扣减库存的 update 语句就会自动触发这一机制。
    • 显式锁定查询:
    • SELECT ... FOR UPDATE:给读取的行加排他锁。
    • SELECT ... LOCK IN SHARE MODE:给读取的行加共享锁(S锁)。
  2. 关键前提:必须通过索引
    这是最容易踩坑的地方。InnoDB 的行锁是加在索引上的,而不是加在记录上的。
    • 命中索引:如果你的 where 条件使用了主键或唯一索引,MySQL 就会精准锁定那一行。
    • 未命中索引:如果 where 条件里的字段没有索引,MySQL 无法定位到具体的行,为了保证并发安全,它会升级为表锁(或者锁定索引树中的所有间隙),导致整个表被锁死。
  3. 普通 SELECT 加锁吗?
    普通的 SELECT 语句(不带 for update)默认不加任何锁。 这是通过 MVCC(多版本并发控制) 实现的“快照读”。即使其他线程正在 update 某一行,你的普通 select 依然能读到数据(读的是历史版本),这样极大地提高了数据库的查询性能。

技术栈

  1. 乐观锁,在更新判断前先判断库存(60),也可以是判断版本号
1.乐观锁,在更新判断前先判断库存(60),也可以是判断版本号
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMappe
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否已经开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        //3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀已经结束!");
        }
        //4.判断库存是否充足
        if (voucher.getStock()<1) {
            //库存不足
            return Result.fail("库存不足!");
        }
        //5.扣减库存(CAS法)
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id",voucherId)
                //CAS法
                //.eq("stock",voucher.getStock())这个失败率太高
                .gt("stock",0)//库存大于零
                .update();
        if (!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //6.3代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}

image-20260111180636223

一人一单

image-20260111221455669

技术栈

  1. 悲观锁绑定查询和修改操作
  2. 集群模式:新增一个tomcat,ctrl+D,修改选项,Java,程序实参
1.悲观锁绑定查询和修改操作,抽取方法ctrl+alt+M是为了不让事务包裹锁使得读到脏数据;事务不能包裹锁;只有基于spring动态代理创建的类上的方法才能实现事务管理;悲观锁是把查询和修改都放在锁内;将userId设置为锁可以避免所有用户抢一把锁
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IVoucherService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否已经开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        //3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀已经结束!");
        }
        //4.判断库存是否充足
        if (voucher.getStock()<1) {
            //库存不足
            return Result.fail("库存不足!");
        }
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //通过用户id拿锁,防止不同用户都拿同一把锁(如果加在方法上)
        //如果没有加toString比较的是对象地址而不是具体值,加上之后会创建一个String对象,也还是不行,所以还得加上intern()
        //intern()会去字符串常量找和这个值一样的对象的地址
        //事务范围大于锁范围可能导致脏数据或数据不一致问题
        synchronized(userId.toString().intern()) {
            //拿到spring代理对象才能保证下面的事务生效
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            //需要先在接口创建
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //5.1查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //5.2判断订单是否存在
        if (count > 0) {
            //用户已经购买过
            return Result.fail("用户已经购买过一次!");
        }
        //6.扣减库存(CAS法)
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                //CAS法
                //.eq("stock",voucher.getStock())这个失败率太高
                .gt("stock",0)//库存大于零
                .update();
        if (!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        voucherOrder.setUserId(userId);
        //6.3代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}

需要引入依赖

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

需要启动类加注解去暴露这个代理对象,默认不暴露

@EnableAspectJAutoProxy(exposeProxy = true)

image-20260111231618379

2.集群模式:新增一个tomcat,ctrl+D,修改选项,Java,程序实参,synchronized是基于JVM监视器的,集群模式下会失效

image-20260112000334148

分布式锁

image-20260112122035140

image-20260112122159955

image-20260112122723220

image-20260112123955365

技术栈

  1. 基于redis实现分布式锁的初级版本
  2. 防止将其他线程的锁删掉,所以删锁之前要先看看锁的标识是不是自己的,也就是校验线程标识
  3. lua脚本保证事务性
1.基于redis实现分布式锁的初级版本吗,如果trycatch在事务里面,可能异常被trycatch消化掉导致事务没有回滚
package com.hmdp.utils;

public interface ILock {

    /**
     * 尝试获取锁
     * @param timeoutSec
     * @return
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}
package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock{

    private String name;
    private StringRedisTemplate stringRedisTemplate;
    private static final String KEY_PREFIX = "lock:";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标识
        long threadId = Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+name, threadId+"", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);//防止自动拆箱时,Boolean为null拆出来就是空指针
    }

    @Override
    public void unlock() {
        //释放锁完成
        stringRedisTemplate.delete(KEY_PREFIX+name);
    }
}
//5.一人一单
    Long userId = UserHolder.getUser().getId();
    //通过用户id拿锁,防止不同用户都拿同一把锁(如果加在方法上)
    //如果没有加toString比较的是对象地址而不是具体值,加上之后会创建一个String对象,也还是不行,所以还得加上intern()
    //intern()会去字符串常量找和这个值一样的对象的地址
    //事务范围大于锁范围可能导致脏数据或数据不一致问题
    //synchronized(userId.toString().intern()) {
    //创建锁对象
    SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
    //获取锁
    boolean isLock = lock.tryLock(1200);
    //判断是否获取锁成功
    if (!isLock){
        //获取锁失败,返回错误或重试,这里是一人一单,所以返回错误
        return Result.fail("不允许重复下单");
    }
    try {
        //拿到spring代理对象才能保证下面的事务生效
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        //需要先在接口创建
        return proxy.createVoucherOrder(voucherId);
        //}
    } finally {
        //下单失败才释放锁
        lock.unlock();;
    }
}
2.防止将其他线程的锁删掉,所以删锁之前要先看看锁的标识是不是自己的,也就是校验线程标识;通过UUID避免不同JVM的线程用一样的(14);存入redis的value中(25,35-37);自动拆箱:防止自动拆箱时,Boolean为null拆出来就是空指针(29)

image-20260112132354419

image-20260112132505331

image-20260112132607790

package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;


import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock{

    private String name;
    private StringRedisTemplate stringRedisTemplate;
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标识
        //long threadId = Thread.currentThread().getId();
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);//防止自动拆箱时,Boolean为null拆出来就是空指针
    }

    @Override
    public void unlock() {
        //获取线程标识
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        //获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //判断标识是否一致
        if (threadId.equals(id)){
            //释放锁完成
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }
    }
}
3.Lua脚本保证事务原子性,一行代码同时执行,判断和删除在lua脚本中执行,满足原子性

127.0.0.1:6379> EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name Rose
OK
127.0.0.1:6379> get name
"Rose"
127.0.0.1:6379> EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name heihei
OK
127.0.0.1:6379> get name
"heihei"

image-20260112140833855

image-20260112140821069

image-20260112141938756

image-20260112145231434

@Override
public void unlock() {
    //调用lua脚本,存放在resource里面
    //这里变成一行代码同时执行,判断和删除在lua脚本中执行,满足原子性
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX+name),
            ID_PREFIX+Thread.currentThread().getId());
}

lua脚本

---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Lenovo.
--- DateTime: 2026/1/12 14:58
---
-- 比较线程标识和锁中标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
    -- 释放锁
    return redis.call('del',KEYS[1])
end
return 0

image-20260112151340415

image-20260112152035862

image-20260112152101295

image-20260112163639716

image-20260112163749868

Redisson

知识点

  1. Spring Container 默认是单例模式。一旦你在配置类中通过@Bean定义了一个对象,Spring 会确保:

    • 该方法只能在启动时执行一次
    • 之后你在任何地方通过@Autowired注入这个对象,得到的都是同一个实例。
    • 这完美解决了您之前担心的“静态代码块 vs 意外重复加载”的问题。
  2. Redisson的逻辑设计是这样的:

    • 如果你不设置leaseTime(或者传 -1):Redisson 会认为你不知道业务执行具体需要多长时间。为了防止死锁,它会给你一个默认 30 秒的锁,并开启看门狗。看门狗每隔 10 秒(lockWatchdogTimeout / 3)帮你续期到 30 秒,直到你手动调用unlock()
    • 如果你设置了leaseTime:Redisson 会认为你对业务执行时间有明确的预期。它会严格给你的时间来设置 Redis 的过期时间。一旦时间到了,Redis 就会自动删锁。它不会帮你自动续期,因为它遵循你“深思熟虑”后的结果。
  3. 在全局系统领域,NPC问题并不是指游戏里的“非玩家角色”,而是由全局系统专家Martin Kleppmann提出的、导致循环锁失效的三大挑战:网络延迟(网络延迟)、进程暂停(进程暂停)、时钟漂移(时钟漂移)。

    这是面试中的高级进阶题,也是理解整个系统复杂性的核心。


    1. N:网络延迟(网络延迟)

    现象:由于网络丢包、拥塞或路由器搁浅,请求在路上花费了超出预期的时间。

    失效场景

    1. 线程 A 请求 Redis 加锁,成功,锁有效时间 10s。
    2. 线程A执行完成业务,发送DEL请求释放锁。
    3. 网络发生了极大的延迟,这个DEL请求在路上花了 15s。
    4. 在第11s时,Redis里的锁已经自动过期了,线程B趁机拿到了锁。
    5. 第15秒时,线程A的释放请求到达,误删了线程B的锁。

    2. P:Process Pause (进程暂停/GC) —— 最致命

    这是 NPC 中最具破坏性的问题,通常由 Java 的Full GC引起。

    失效场景

    1. 线程A获取锁成功(TTL = 30s)。
    2. 发生 Full GC,Java 虚拟机进入STW (Stop-The-World)状态,所有代码停止运行。
    3. 线程A暂停了40s。
    4. 在第31s,Redis认为锁到期,自动将其删除。
    5. 线程B获取了锁,开始操作数据库。
    6. 第41s,线程A醒过来了,它以为自己还持有锁(因为它不知道自己刚才“断片”了),也开始操作数据库。

    后果:两个线程同时持有锁并操作资源,一把锁完全失效。


    3. C:Clock Drift(时钟漂移)

    现象:不同服务器的物理时钟不是绝对同步的。即使有NTP协议,也可能发生几十毫秒的跳转。

    故障场景:Redis 的过渡时间是依赖服务器本地时钟的。如果 Redis 集群中,机器 A 的时钟比机器 B 快很多。

    1. 锁定在A上设置了10s过渡。
    2. 由于时钟不一致,A认为已经过了10s,提前释放了锁。
    3. 而客户端根据自己的时间认为尚未解锁,继续操作。

    4.解决方案:如何对抗NPC?

    没有任何一种技术能够100%消除NPC(这是物理规律),但我们可以通过架构设计来规避风险:

    ① 引入Fencing Token(栅栏令牌)

    这是解决进程暂停的标准解决方案。

    • 原理:每次获取锁时,由锁服务(如ZooKeeper或数据库)生成一个全局递增的序号。
    • 校验:在操作数据库时,带上这个序号。数据库端或资源服务器检查:如果当前请求的Token小于上一个已经处理的Token,则拒绝写入。
    • 效果: 升级线程 A 因为 GC 启动后尝试写入,因为它的 Token 是旧的,操作会被拒绝。

    ② 使用Redisson看门狗(针对P)

    虽然看门狗无法解决 STW 在持续期期间的问题,但它可以在正常的网络波动下延长锁定的深度,减少过渡期带来的冲突问题。

    ③ 数据库兜底(最终防线)

    不要信任全球化锁。在核心业务(如扣减库存)上,必须完全满足数据库层面的约束:

    • 乐观锁update ... set version = version + 1 where version = #{version}
    • 原子操作update ... set stock = stock - 1 where stock > 0

    ④选型建议

    • Redis (Redlock):偏向高可用和性能,但在极端NPC下一致性较弱。
    • ZooKeeper / Etcd:基于CP协议(一致性)。它们采用“临时节点+心跳”机制。如果发生进程暂停,ZK会在心跳超时后自动脱锁,虽然也有延迟,但比Redis基于物理时间的TTL机制更安全。

技术栈

  1. Redisson的使用,先引入依赖,然后配置配置类,就可以直接使用(74,77)

  2. 分布式锁原理,看门狗机制(等待并定期更新ttl)

1.Redisson的使用,先引入依赖,然后配置配置类,就可以直接使用(74,77)
<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>
package com.hmdp.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        //配置
        Config config = new Config();
        //useSingleServer()单例模式
        config.useSingleServer().setAddress("redis://192.168.100.128:6379").setPassword("123321");
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IVoucherService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private RedissonClient redissonClient;
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否已经开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        //3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀已经结束!");
        }
        //4.判断库存是否充足
        if (voucher.getStock()<1) {
            //库存不足
            return Result.fail("库存不足!");
        }
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //通过用户id拿锁,防止不同用户都拿同一把锁(如果加在方法上)
        //如果没有加toString比较的是对象地址而不是具体值,加上之后会创建一个String对象,也还是不行,所以还得加上intern()
        //intern()会去字符串常量找和这个值一样的对象的地址
        //事务范围大于锁范围可能导致脏数据或数据不一致问题
        //synchronized(userId.toString().intern()) {
        //创建锁对象
        //SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //获取锁
        //boolean isLock = lock.tryLock(1200);
        boolean isLock = lock.tryLock();
        //判断是否获取锁成功
        if (!isLock){
            //获取锁失败,返回错误或重试,这里是一人一单,所以返回错误
            return Result.fail("不允许重复下单");
        }
        try {
            //拿到spring代理对象才能保证下面的事务生效
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            //需要先在接口创建
            return proxy.createVoucherOrder(voucherId);
            //}
        } finally {
            //下单失败才释放锁
            lock.unlock();;
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //5.1查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //5.2判断订单是否存在
        if (count > 0) {
            //用户已经购买过
            return Result.fail("用户已经购买过一次!");
        }
        //6.扣减库存(CAS法)
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                //CAS法
                //.eq("stock",voucher.getStock())这个失败率太高
                .gt("stock",0)//库存大于零
                .update();
        if (!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        voucherOrder.setUserId(userId);
        //6.3代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}

image-20260112170819122

通过记录线程,和记录获取锁次数,使用hash结构

image-20260112171636733

image-20260112171800852

image-20260112171955177

2.分布式锁原理,看门狗机制(等待并定期更新ttl)

image-20260112191324264

Redisson分布式锁原理总结

image-20260112191921652

可重入:反复获得一个锁

可重试,获取失败再看看能不能重试(给定时间内)

超时续约:看门狗机制重置锁的超时时间,一定是获取锁以后才能这样

技术栈

  1. linux中部署多个redis
  2. MultiLock-redis的联锁
1.linux中部署多个redis
# 1. 定义源文件路径

ORIGIN_CONF="/usr/local/src/redis-6.2.6/redis.conf"

# 2. 确保必要的目录存在

mkdir -p /var/log/redis
mkdir -p /var/lib/redis/6379 /var/lib/redis/6380 /var/lib/redis/6381

# 3. 循环创建配置并启动 (6379, 6380, 6381)

for PORT in 6379 6380 6381; do
    CONF_FILE="/etc/redis_${PORT}.conf"
# 复制原始配置
cp $ORIGIN_CONF $CONF_FILE

# 使用 sed 修改关键参数
sed -i "s/^port .*/port $PORT/" $CONF_FILE
sed -i "s/^bind .*/bind 0.0.0.0/" $CONF_FILE
sed -i "s/^protected-mode .*/protected-mode no/" $CONF_FILE
sed -i "s|^dir .*|dir /var/lib/redis/$PORT|" $CONF_FILE
sed -i "s|^pidfile .*|pidfile /var/run/redis_$PORT.pid|" $CONF_FILE
sed -i "s|^logfile .*|logfile /var/log/redis/redis_$PORT.log|" $CONF_FILE

# 开启后台运行(非常重要,否则会卡住终端)
sed -i "s/^daemonize no/daemonize yes/" $CONF_FILE

# 启动 Redis
redis-server $CONF_FILE
echo "Redis 端口 $PORT 启动指令已下达"
done
2.MultiLock-redis的联锁

image-20260113125559318

Redis秒杀优化

image-20260113130400848

image-20260113130748849

image-20260113130937874

image-20260113131527378

image-20260113131622809image-20260113131623261

技术栈

  1. 分开操作redis和使用异步线程执行数据库操作,需要编写lua脚本保证一致性
  2. 要使得匿名内部类这个任务(或者其他任务或者其他方法)在项目初始化之后就启动,要使用spring提供的注解@PostConstruct
  3. 线程池的创建与使用,往线程池中submit任务
  4. 创建子线程后的threadLocal将不能拿到,代理对象也是基于threadLocal也拿不到,要通过类常量定义
1.分开操作redis和使用异步线程执行数据库操作,需要编写lua脚本保证一致性,通过静态代码块加载lua脚本
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Lenovo.
--- DateTime: 2026/1/13 13:33
---
--1.参数列表
--1.1.优惠券id
local voucherId = ARGV[1]
--1.2.用户id
local userId = ARGV[2]

--2.数据key
--2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

--3.脚本业务
--3.1.判断库存是否充足
if (tonumber(redis.call('get',stockKey)) <= 0) then
    --3.2库存不足,返回1
    return 1
end
--3.2.判断用户是否下单 
if (tonumber(redis.call('sismember',orderKey,userId)) == 1) then
    --3.3存在,说明是重复下单,返回2
    return 2
end
--3.4.扣库存
redis.call('incrby',stockKey,-1)
--3.5.保存用户
redis.call('sadd',orderKey,userId)
2.要使得匿名内部类(17~31)这个任务(或者其他任务或者其他方法)在项目初始化之后就启动,要使用spring提供的注解@PostConstruct(13),提交任务(15),通过静态代码块加载lua脚本(1-6)
3.线程池的创建与使用(10),往线程池中submit任务(15)
4.创建子线程后的threadLocal将不能拿到(34-35),代理对象也是基于threadLocal也拿不到,要通过类常量定义(66、1.3-105)
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//基于spring的注解,在初始化后加载这个方法
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());//向线程池中提交这个任务
    }
    //匿名内部类
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while (true){
                try {
                    //1.获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2.创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常",e);
                }
            }
        }

        private void handleVoucherOrder(VoucherOrder voucherOrder) {
            //1.由于这里是新线程,没办法通过threadlocal获取用户id
            Long userId = voucherOrder.getUserId();
            //2.创建锁对象
            //SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
            RLock lock = redissonClient.getLock("lock:order:" + userId);//保证并发安全
            //RLock lock1 = redissonClient.getLock("lock:order:" + userId);//保证并发安全
            //RLock lock2 = redissonClient2.getLock("lock:order:" + userId);//保证并发安全
            //RLock lock3 = redissonClient3.getLock("lock:order:" + userId);//保证并发安全
            //3.获取锁
            //boolean isLock = lock.tryLock(1200);
            // 合成红锁
            //RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
            boolean isLock = lock.tryLock();
            //4.判断是否获取锁成功
            if (!isLock){
                //获取锁失败,返回错误或重试,这里是一人一单,所以返回错误
                log.error("不允许重复下单");
                return;
            }
            try {
                //拿到spring代理对象才能保证下面的事务生效,底层基于threalLocal,由于这里是基于线程池不是主线程,所以也拿不到
                //IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                //需要先在接口创建
                proxy.createVoucherOrder(voucherOrder);
                //}
            } finally {
                //下单失败才释放锁
                lock.unlock();;
            }
        }
    }

    private IVoucherOrderService proxy;
    /**
     * 异步抢单
     * @param voucherId
     * @return
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,//传入的lua脚本
                Collections.emptyList(),//传入的key的参数,KEYS[1],虽然这里没有
                voucherId.toString(), userId.toString()//传入的value参数ARGV[1]和ARGV[2]
        );
        //2.判断结果是否为0
        int r = result.intValue();
        if (r != 0){
            //2.1.不为0,代表没有购买资格
            return Result.fail(r == 1?"库存不足":"不能重复下单");
        }
        //2.2.为0,有购买资格,把下单信息保存到阻塞队列
//        long orderId = redisIdWorker.nextId("order");
//        //TODO 保存阻塞队列
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //2.3.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //2.4.用户id
        voucherOrder.setUserId(userId);
        //2.5.代金券id
        voucherOrder.setVoucherId(voucherId);
        //2.6.放入一个阻塞队列(先创建)
        orderTasks.add(voucherOrder);
        //3.获取代理对象,放到队列里面
        //拿到spring代理对象才能保证下面的事务生效,底层基于threalLocal,由于是基于线程池不是主线程,子线程所以也拿不到,只能在主线程拿到
        //注入上面的成员变量,放到当前类
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3.返回订单id
        return Result.ok(orderId);
    }
    
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        //5.一人一单
        //改完异步之后是子线程,不能通过threadLocal获取
        //Long userId = UserHolder.getUser().getId();
        Long userId = voucherOrder.getUserId();
        //5.1查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder).count();
        //5.2判断订单是否存在
        if (count > 0) {
            //用户已经购买过
            log.error("用户已经购买过一次!");
            //return Result.fail("用户已经购买过一次!");
            return;
        }
        //6.扣减库存(CAS法)
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                //CAS法
                //.eq("stock",voucher.getStock())这个失败率太高
                .gt("stock",0)//库存大于零
                .update();
        if (!success){
            //扣减失败
            log.error("库存不足!");
            //return Result.fail("库存不足!");
            return;
        }
        //7.创建订单
//        VoucherOrder voucherOrder = new VoucherOrder();
//        //7.1订单id
//        long orderId = redisIdWorker.nextId("order");
//        voucherOrder.setId(orderId);
//        //7.2用户id
//        voucherOrder.setUserId(userId);
//        //7.3代金券id
//        voucherOrder.setVoucherId(voucherOrder);
        save(voucherOrder);

        //8.返回订单id
        //return Result.ok(orderId);
    }

JVM 的阻塞队列 内存限制(1024*1024) 数据安全 (服务宕机)

image-20260113155507327

Redis消息队列实现异步秒杀

image-20260113160030955

image-20260113160112169

image-20260113160905298

image-20260113161349753

image-20260113161539590

image-20260113162215078

image-20260113162632130

image-20260113162814689

可能漏读:读到一条信息后处理,还没进行下一次读取的间隔中如果又添加了多于一条信息,只有最新的一条会被读取到

image-20260113163041129

image-20260113163614962

image-20260113163753032

image-20260113164112764

消费者C1,从队列S1监听的消费者组G1中,读取下一个未消费的消息,等待时间为2S

image-20260113164458443

image-20260113165339565

image-20260113165743424

生产者通过基于stream的消息队列发送给消费者组之后,再由另一个(在这个消费组中的)消费者读取消费者组,以实现避免漏读

知识点

  1. 消费者组的机制:g1是监听消息队列s1,c1是g1消费者组中的一个消费者

    "XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >"
    

    这句话的意思是:

    “我是 消费组 g1 里的 消费者 c1,请从 消息队列 s1 中给我分配 1最新(>`) 的消息,如果没有就等 2000ms。”

    在 Redis Stream 的 消费者组(Consumer Group) 模式下,同一组内的多个消费者是“竞争关系”,消息只会被其中一个消费者接收到。

    1. 核心机制:负载均衡(Load Balancing)

    当你使用 XREADGROUP 指令时,Redis 会确保:

    • 生产者发送一条消息到 Stream。
    • 消费组收到通知。
    • 组内只有一个消费者能抢到这条消息。
    • 这条消息会被标记在该消费者的 PEL (Pending Entries List,待处理列表) 中,直到该消费者发送 XACK

    为什么要这么设计? 这是为了横向扩展处理能力。比如你的秒杀业务每秒有 1000 个订单消息,一个线程(消费者)处理不过来,你可以开 3 个线程都在同一个消费组里。Redis 会把 1000 条消息均匀分配给这 3 个线程,实现真正的并发处理。


    2. 只有一种情况会“每一个都接收到”

    只有当你定义了 “多个不同的消费组” 时,才会出现重复接收。

    • 消费组 A 的消费者 1 拿到了消息。
    • 消费组 B 的消费者 1 也能拿到同一条消息。 这通常用于“发布-订阅”模式,比如:一个组负责“写数据库”,另一个组负责“发推送通知”。

image-20260113170751068

image-20260113171915213

image-20260113172158583

技术栈

  1. 创建消费者组时可以顺带把stream消息队列一起创建
  2. 直接在lua脚本中往stream消息队列里面传入订单
  3. 转换为String时,包装类型和基本数据类型不一样
  4. 从消息队列中取出,并转换为对象
1.创建消费者组时可以顺带把stream消息队列一起创建

127.0.0.1:6379> XGROUP CREATE stream.orders g1 0 MKSTREAM

2.直接在lua脚本中往stream消息队列里面传入订单(35-36)
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Lenovo.
--- DateTime: 2026/1/13 13:33
---
--1.参数列表
--1.1.优惠券id
local voucherId = ARGV[1]
--1.2.用户id
local userId = ARGV[2]
--1.3.订单id
local orderId = ARGV[3]

--2.数据key
--2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

--3.脚本业务
--3.1.判断库存是否充足
if (tonumber(redis.call('get',stockKey)) <= 0) then
    --3.2库存不足,返回1
    return 1
end
--3.2.判断用户是否下单
if (tonumber(redis.call('sismember',orderKey,userId)) == 1) then
    --3.3存在,说明是重复下单,返回2
    return 2
end
--3.4.扣库存
redis.call('incrby',stockKey,-1)
--3.5.保存用户
redis.call('sadd',orderKey,userId)
--3.6.发送消息到队列中 XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
3.转换为String时,包装类型和基本数据类型不一样(13-17),传入消息队列(在lua里面)
/**
 * 异步抢单
 * @param voucherId
 * @return
 */
@Override
public Result seckillVoucher(Long voucherId) {
    //获取用户
    Long userId = UserHolder.getUser().getId();
    //获取订单id
    long orderId = redisIdWorker.nextId("order");
    //1.执行lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,//传入的lua脚本
            Collections.emptyList(),//传入的key的参数,KEYS[1],虽然这里没有
            voucherId.toString(), userId.toString(),String.valueOf(orderId)//传入的value参数ARGV[1]和ARGV[2]/ARGV[3]
    );
    //2.判断结果是否为0
    int r = result.intValue();
    if (r != 0){
        //2.1.不为0,代表没有购买资格
        return Result.fail(r == 1?"库存不足":"不能重复下单");
    }

    //3.获取代理对象,放到队列里面
    //拿到spring代理对象才能保证下面的事务生效,底层基于threalLocal,由于是基于线程池不是主线程,子线程所以也拿不到,只能在主线程拿到
    //注入上面的成员变量,放到当前类
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    //4.返回订单id
    return Result.ok(orderId);
}
4.从消息队列中取出,并转换为对象(18-20)
  1. 为什么是 MapRecord<String, Object, Object>?
    在 Redis Stream 中,一条消息(Record)并不是一个简单的字符串,而是一个键值对集合(Fields-Values)。

第一个 String:代表 Stream 的名称(即你的 s1 或 stream.orders)。

第二个 Object:代表 消息的 ID(即那个由 * 自动生成的,类似于 1736764800000-0 的 ID)。

第三个 Object:代表 消息的正文内容,它是一个 Map 结构。

  1. “怎么有三个” 是什么意思?
    你提到的“三个”实际上是指你在 Lua 脚本中通过 XADD 存入的 3 个字段。

你在 Lua 脚本中执行了: redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)

这导致 Redis 存储的消息内容(Map)里包含了 3 个键值对:

第一层:List (快递车) 当你执行 readGroup 时,返回的是一个 List。即便你设置了 COUNT 1,它依然是一个列表,只是里面只装了一个包裹。

  • list.get(0) 的作用是:把这唯一的包裹从车上拿下来。 此时你手里拿到的就是 MapRecord 对象。

第二层:MapRecord (包裹箱) 这个对象里依然包含三样东西:

  1. Stream Name (寄件地址)
  2. RecordId (快递单号)
  3. Value (箱子里的货物)
private class VoucherOrderHandler implements Runnable{
    String queueName = "stream.orders";
    @Override
    public void run() {
        while (true){
            try {
                //1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
                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()));
                //2.判断消息获取是否成功
                if (list == null||list.isEmpty()){
                    //2.1.如果不存在,获取失败,继续下一次循环
                    continue;
                }
                //2.2.如果获取成功,可以下单
                //3.解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                handleVoucherOrder(voucherOrder);
                //3.ACK确认 SACK stream.orders g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
            } catch (Exception e) {
                log.error("处理订单异常",e);
                handlePendingList();
            }
        }
}

    private void handlePendingList() {
        while (true){
            try {
                //1.获取pendingList中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1),
                        StreamOffset.create(queueName, ReadOffset.from("0")));
                //2.判断消息获取是否成功
                if (list == null||list.isEmpty()){
                    //2.1.如果不存在,获取失败,pendingList没有异常信息
                    break;
                }
                //2.2.如果获取成功,可以下单
                //3.解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                handleVoucherOrder(voucherOrder);
                //3.ACK确认 SACK stream.orders g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
            } catch (Exception e) {
                log.error("处理pendingList订单异常",e);
                try {
                    Thread.sleep(20);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            }
        }
    }
posted @ 2026-01-13 18:17  David大胃  阅读(0)  评论(0)    收藏  举报