Redis学习笔记(实战篇3)

一、分布式锁-redission

1. 存在的问题
(1) 不可重入:
java 复制代码
// 方法A加了分布式锁
public void methodA() {
    lock(); // 线程拿到锁
    methodB(); // 方法B也加了同一个分布式锁
    unlock();
}

// 方法B也加了同一个分布式锁
public void methodB() {
    lock(); // 同一个线程再次申请锁,被拒绝 → 死锁
    // ...业务
    unlock();
}

线程执行methodA拿到锁后,调用methodB时,再次尝试拿同一个锁,这时候分布式锁会认为 "锁已经被别人占了",导致线程自己阻塞自己,形成死锁

(2) 不可重试:

现在的setnx实现,线程尝试拿锁一次,如果失败(返回false),就直接结束了,没有 "再试一次" 的机制。但实际业务里(比如秒杀、订单创建),锁竞争往往是短暂的,线程应该可以重试几次,提高拿到锁的成功率。

(3) 超时释放:

我们给锁加了过期时间(比如 30 秒),本来是为了防止 "服务挂了锁不释放" 导致死锁,但带来了新问题:如果业务执行时间超过了锁的过期时间 ,锁会自动释放,这时候其他线程就能拿到锁,操作同一个资源,导致数据不一致

(4) 主从一致性:

① Redis 主从集群的原理

  • 主节点(Master):负责写操作(加锁、解锁)
  • 从节点(Slave):负责读操作,主节点的数据会异步同步到从节点
  • 如果主节点挂了,集群会把一个从节点升级为新的主节点

② 问题场景

  • 线程 A 向主节点加锁成功,主节点还没把这个锁数据同步到从节点
  • 主节点突然宕机了
  • 集群选举一个从节点成为新主节点,但这个新主节点没有刚才的锁数据,认为锁不存在
  • 线程 B 来拿锁,直接成功,这时候就出现了两个线程同时持有同一个锁的情况,锁失效,并发安全问题爆发。
2. Redission快速入门
(1) 配置Redisson客户端
java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6379")
            .setPassword("123321");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
}
(2) 如何使用Redission的分布式锁
java 复制代码
@Resource
private RedissionClient redissonClient;

@Test
void testRedisson() throws Exception{
    //获取锁(可重入),指定锁的名称
    RLock lock = redissonClient.getLock("anyLock");
    //尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
    //判断获取锁成功
    if(isLock){
        try{
            System.out.println("执行业务");          
        }finally{
            //释放锁
            lock.unlock();
        }
        
    }
}
(3) VoucherOrderServiceImpl
java 复制代码
@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("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        //创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
        //SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //获取锁对象
        boolean isLock = lock.tryLock();
       
		//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
 }
3. redission可重入锁原理
(1) 3 个核心参数
参数 含义 作用
KEYS[1] 锁的大 key(锁名称) 代表这把锁的整体,用来判断锁是否存在
ARGV[1] 锁的过期时间(毫秒) 防止锁死锁,即使客户端宕机也会自动释放
ARGV[2] 锁的小 key(持有者标识) 格式:客户端ID + ":" + 线程ID,用来判断锁是否属于当前线程
(2) 脚本的核心逻辑
Lua 复制代码
-- 步骤1:锁不存在 → 直接加锁
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);  -- 新建hash锁,小key对应值=1(第一次持有)
    redis.call('pexpire', KEYS[1], ARGV[1]); -- 给锁设置过期时间
    return nil; -- 返回nil = 加锁成功
end;

-- 步骤2:锁存在,但属于当前线程 → 可重入
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 重入次数+1
    redis.call('pexpire', KEYS[1], ARGV[1]);    -- 刷新过期时间
    return nil; -- 返回nil = 重入成功
end;

-- 步骤3:锁存在且不属于当前线程 → 抢锁失败
return redis.call('pttl', KEYS[1]); -- 返回锁的剩余过期时间
4. redission锁重试和WatchDog机制
(1) Lua 抢锁逻辑
条件 操作 返回值 含义
锁不存在 插入锁(Hash 结构),设置过期时间 null 抢锁成功
锁存在且属于当前线程 重入次数 + 1,刷新过期时间 null 可重入成功
锁存在且不属于当前线程 无操作 锁的剩余过期时间(ttl) 抢锁失败
(2) lock() 核心抢锁流程
java 复制代码
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
    return;
}

说明:

① Long ttl = tryAcquire(-1, leaseTime, unit, threadId);

  • -1:是 waitTime 的默认值(表示无限等待,直到抢到锁);
  • leaseTime:锁的过期时间(无参 lock() 时默认 -1,带参 lock(10, TimeUnit.SECONDS) 时为 10);
  • unit:时间单位(如 TimeUnit.MILLISECONDS);
  • threadId:当前线程 ID。

② 返回值 ttl

  • null → 抢锁 / 可重入成功;
  • 非 null 数字 → 锁被其他线程持有,返回锁的剩余过期时间(比如返回 20000 代表锁还有 20 秒过期)。
(3) WatchDog(看门狗)续约机制
java 复制代码
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                        TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    if (e != null) {
        return;
    }

    // lock acquired
    if (ttlRemaining == null) {
        scheduleExpirationRenewal(threadId);
    }
});
return ttlRemainingFuture;

说明:

tryLockInnerAsync(...) 的第二个参数:commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()

  • 含义:获取 Redisson 配置的「看门狗默认超时时间」,默认值是 30 秒(30000 毫秒);
  • 作用:把锁的初始过期时间设为 30 秒(替代用户传入的 leaseTime)。

② if (ttlRemaining == null) { scheduleExpirationRenewal(threadId); }

  • 逻辑:只有抢锁成功(ttlRemaining=null),才调用 scheduleExpirationRenewal(threadId)
  • scheduleExpirationRenewal:核心作用是「启动看门狗续约线程」,是连接抢锁和续约的关键方法。
5. redission锁的MutiLock原理
(1) 存在的问题

我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

(2) 解决方案

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功。假设现在某个节点挂了,那么它去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

二、秒杀优化

1. 异步秒杀思路

(1) 当用户下单之后,判断库存是否充足只需要到redis中去根据key找对应的value是否大于0即可。如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明它可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作。

(2) 校验通过后,无需等待完整下单流程完成,直接给用户返回 "下单受理成功"(附带订单 ID),同时将下单任务丢入异步队列;后台单独线程消费异步队列中的任务,慢慢执行完整的数据库下单逻辑(创建订单、扣减库存等);前端通过返回的订单 ID,查询异步下单的最终结果(成功 / 失败)。

2. Redis完成秒杀资格判断
(1) 需求
  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
(2) 代码实现

完整lua表达式

Lua 复制代码
-- 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.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
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

说明:

① Lua 里的 .. 是什么

..Lua 语言的字符串拼接运算符 ,作用和 Java 里用 + 拼接字符串(比如 "a" + "b")完全一样,只是语法不同。

② if(tonumber(redis.call('get', stockKey)) <= 0) then return 1 end

tonumber() 是 Lua 的内置函数,作用是把字符串类型的数字 转成数值类型

VoucherOrderServiceImpl

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    //获取用户
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");
    // 1.执行lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );
    int r = result.intValue();
    // 2.判断结果是否为0
    if (r != 0) {
        // 2.1.不为0 ,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    //TODO 保存阻塞队列
    // 3.返回订单id
    return Result.ok(orderId);
}
3. 基于阻塞队列实现秒杀优化
java 复制代码
//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
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.获取用户
            Long userId = voucherOrder.getUserId();
            // 2.创建锁对象
            RLock redisLock = redissonClient.getLock("lock:order:" + userId);
            // 3.尝试获取锁
            boolean isLock = redisLock.lock();
            // 4.判断是否获得锁成功
            if (!isLock) {
                // 获取锁失败,直接返回失败或者重试
                log.error("不允许重复下单!");
                return;
            }
            try {
				//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
                proxy.createVoucherOrder(voucherOrder);
            } finally {
                // 释放锁
                redisLock.unlock();
            }
    }
     //a
	private BlockingQueue<VoucherOrder> orderTasks =new  ArrayBlockingQueue<>(1024 * 1024);

    @Override
    public Result seckillVoucher(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        long orderId = redisIdWorker.nextId("order");
        // 1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(), String.valueOf(orderId)
        );
        int r = result.intValue();
        // 2.判断结果是否为0
        if (r != 0) {
            // 2.1.不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        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.获取代理对象
         proxy = (IVoucherOrderService)AopContext.currentProxy();
        //4.返回订单id
        return Result.ok(orderId);
    }
     
      @Transactional
    public  void createVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
           log.error("用户已经购买过了");
           return ;
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            log.error("库存不足");
            return ;
        }
        save(voucherOrder);
 
    }

说明:

① private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

这是 Java 的线程池Executors.newSingleThreadExecutor() 会创建一个「只有 1 个工作线程」的线程池 ------ 所有任务都由这 1 个线程按顺序处理。

② @PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); }

@PostConstruct:Spring 注解,作用是「当前类被 Spring 初始化完成后(项目启动时),立即执行这个方法」;

③ private class VoucherOrderHandler implements Runnable { ... }

这是一个内部线程任务类 ,实现了 Runnable 接口(Java 中 "可被线程执行的任务" 都要实现这个接口),里面的 run() 方法是线程要执行的核心逻辑。

三、Postman测试方法

1. 发送验证码(不需要登录)

(1) 请求方式:POST

(2) 直连后端:http://localhost:8082/user/code?phone=13100000000

(3) 预期响应

bash 复制代码
{
  "success": true
}
2. 登录获取 token

(1) 请求方式:POST

(2) 直连后端:http://localhost:8082/user/login

(3) Body(选择 raw,格式 JSON):

bash 复制代码
{
  "phone": "13100000000",
  "code": "518208"
}

注意:code 是第一步发送验证码后,在控制台日志里看到的 6 位数字

(4) 预期响应

bash 复制代码
"1c3e03a8a5734dd1a8a5285c0d49a63c"

这个字符串就是 token,复制保存下来

3. 测试登录状态(验证 token 是否有效)

(1) 请求方式:GET

(2) 直连后端:http://localhost:8082/user/me

(3) Headers:

authorization: 1c3e03a8a5734dd1a8a5285c0d49a63c

(4) 预期响应

bash 复制代码
{
  "success": true,
  "data": {
    "id": 1,
    "nickName": "user_xxx",
    "icon": "..."
  }
}
4. 秒杀下单(需要登录)

(1) 请求方式:POST

(2) 直连后端:http://localhost:8082/voucher-order/seckill/10

(3) Headers:

authorization: 1c3e03a8a5734dd1a8a5285c0d49a63c

(4) Body:不需要

(5) 可能响应:

bash 复制代码
{
  "success": true,
  "data": 1234567890
}

四、JMeter使用方法

1. 添加线程组

(1) 右键 "测试计划" → 添加 → 线程(用户) → 线程组

(2) 配置

  • 线程数(用户数):并发用户数(如 100)
  • Ramp-Up时间(秒):启动时间(如 10)
  • 循环次数:每个线程执行次数(如 1)
2. 添加 HTTP 请求

(1) 右键 "线程组" → 添加 → 取样器 → HTTP 请求

(2) 配置:

  • 名称:秒杀下单(自定义)
  • 服务器名称或IP:localhost(或后端服务器IP)
  • 端口号:8081(或你的后端端口)
  • 方法:POST
  • 路径:/voucher-order/seckill/10(你的秒杀接口路径)
3. 添加 HTTP 信息头管理器

(1) 右键 "线程组" → 添加 → 配置元件 → HTTP 信息头管理器

(2) 配置:

  • 名称:authorization
  • 值:你的登录token(例如:1c3e03a8a5734dd1a8a5285c0d49a63c)
4. 添加监听器(查看结果)

右键 "线程组" → 添加 → 监听器 → 选择:

  • 察看结果树:查看每个请求的详细响应(调试用)
  • 汇总报告:查看统计信息(吞吐量、错误率等)
  • 聚合报告:更详细的性能报告
5. 运行测试

点击顶部工具栏的绿色"启动"按钮

相关推荐
炽烈小老头2 小时前
【 每天学习一点算法 2026/03/19】子集
学习·算法
Purple Coder2 小时前
龙华寺-我会发顶刊的
学习
bennybi2 小时前
Openclaw 实践笔记
笔记·ai·openclaw
AI视觉网奇2 小时前
aigc 生成几何图 整理笔记
笔记·aigc
靠沿2 小时前
【优选算法】专题十六——BFS解决最短路径问题
redis·算法·宽度优先
今儿敲了吗2 小时前
python基础学习笔记第五章——容器
笔记·python·学习
知识分享小能手2 小时前
edis入门学习教程,从入门到精通,Redis编程开发知识点详解(4)
数据库·redis·学习
三水不滴3 小时前
Elasticsearch 实战系列(二):SpringBoot 集成 Elasticsearch,从 0 到 1 实现商品搜索系统
经验分享·spring boot·笔记·后端·elasticsearch·搜索引擎
Ynchen. ~3 小时前
快速复习笔记(随笔)
笔记