Redis学习笔记(实战篇2)

一、优惠券秒杀

1. 全局唯一ID

为了增加ID的安全性,我们不直接使用Redis自增的数值,而是拼接一些其它信息:

ID的组成部分:符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

2. Redis实现全局唯一Id
(1) RedisIdWorker
java 复制代码
@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    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"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

说明:

① String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));

  • 日期拆分 Redis 计数器的 Key,避免单个 Key 的计数器值无限增大(比如累计到几十亿,既占内存又不方便管理);
  • 让每个日期的计数器从1开始,减少 ID 的长度(如果全局一个计数器,数值会快速变大);
  • 方便按日期统计 / 清理数据(比如删除 30 天前的计数器 Key)。

举个例子:订单业务的 Key,2026-03-11 是icr:order:2026:03:11,2026-03-12 就变成icr:order:2026:03:12,每天的计数器独立。

② return timestamp << COUNT_BITS | count.longValue();

这是核心的位运算逻辑,目的是把「相对时间戳」和「每日计数器」拼接成一个完整的 ID。

  • timestamp << COUNT_BITS:把timestamp(相对时间戳)左移 32 位 ,放到 64 位long的高位区域;比如timestamp=100,左移 32 位后变成100 * 2^32(二进制就是 100 后面跟 32 个 0);
  • count.longValue():把 Redis 返回的计数器值转为long(因为返回的是Long包装类);
  • |(按位或):把高位的时间戳和低位的计数器拼接成一个数(因为两部分的二进制位不重叠,按位或等价于拼接)。
(2) 测试类
java 复制代码
@Test
void testIdWorker() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300);

    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            long id = redisIdWorker.nextId("order");
            System.out.println("id = " + id);
        }
        latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        es.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("time = " + (end - begin));
}

说明:

CountDownLatch 的工作原理:

  • 你(主线程)要统计 300 个工人(子线程)完成 "每人生产 100 个零件(生成 100 个 ID)" 的总耗时;
  • 你先给工人发了一个 "倒计时牌"(CountDownLatch (300)),初始数字是 300;
  • 每个工人完成自己的 100 个零件后,就把倒计时牌数字减 1(latch.countDown());
  • 你站在旁边等(latch.await()),直到倒计时牌数字变成 0(所有工人都完成),才开始计算从开工到收工的总时间;
  • 如果没有这个倒计时牌,你可能刚安排完工人就看表,此时工人还在干活,统计的时间毫无意义。
3. 实现秒杀下单
(1) 思路分析
  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单

  • 库存是否充足,不足则无法下单

① 当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件。

② 比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

(2) 代码实现

VoucherOrderServiceImpl

java 复制代码
@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,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).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);

    return Result.ok(orderId);

}

说明:

① Resource private ISeckillVoucherService seckillVoucherService;

核心解释 :这是 Spring 的依赖注入,目的是引入操作「秒杀优惠券表(SeckillVoucher)」的服务类。

  • ISeckillVoucherService:MyBatis-Plus 自动生成的服务接口,专门用于操作SeckillVoucher(秒杀优惠券)表,包含查询、修改、删除等方法;
  • 为什么需要它 :秒杀逻辑的第一步是查询优惠券的秒杀时间、库存等信息(这些存在SeckillVoucher表),所以必须注入这个服务来操作该表。

② 扣减库存的 update 链式调用

java 复制代码
seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .update();

这是MyBatis-Plus 的链式更新语法,目的是扣减秒杀优惠券的库存,拆解每一步:

代码片段 作用
seckillVoucherService.update() 初始化 MyBatis-Plus 的更新构造器(UpdateWrapper)
.setSql("stock = stock - 1") 设置要执行的 SQL 片段:将库存字段减 1(直接写 SQL 片段,而非 set ("stock", 具体值),避免多线程下覆盖)
.eq("voucher_id", voucherId) 添加更新条件:只更新voucher_id等于传入的优惠券 ID 的记录
.update() 执行最终的更新操作,对应 SQL:UPDATE seckill_voucher SET stock = stock - 1 WHERE voucher_id = ?
4. 库存超卖问题分析
(1) 存在的问题

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

(2) 解决方案(我们采用乐观锁)

① 乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功。这套机制的核心逻辑在于:如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对它进行过修改,它的操作就是安全的,如果不大1,则数据被修改过,乐观锁还有一些变种的处理方式比如cas

② 代码实现

java 复制代码
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",voucher.getStock())
                .update(); 

说明:

.gt("stock", voucher.getStock()) ✅ 核心防超卖逻辑

  • gtgreater than 的缩写,意为大于
  • 这是乐观锁 / 库存校验的关键 :要求数据库中当前的库存值 ,必须大于 代码中拿到的预期库存voucher.getStock())。
  • 为什么能防超卖?多线程并发请求时,只有当数据库库存确实大于预期值时,才会执行扣减;如果某线程执行时库存已不足,该条件会直接失败,避免 stock 变成负数。
5. 一人一单
(1) 版本1.0
java 复制代码
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {

	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.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }

        // 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(voucherId);
        save(voucherOrder);

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

说明:

synchronized 修饰整个方法 → 全局串行,性能崩溃

java 复制代码
public synchronized Result createVoucherOrder(Long voucherId) {

所有用户的秒杀请求都要排队执行(比如 1000 个用户下单,只能一个接一个处理);

❌ 本来锁的目标是 "一人一单"(只锁单个用户),结果锁了所有用户,完全违背并发设计初衷。

(2) 版本2.0
java 复制代码
@Transactional
public  Result createVoucherOrder(Long voucherId) {
	Long userId = UserHolder.getUser().getId();
	synchronized(userId.toString().intern()){
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

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

        // 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(voucherId);
        save(voucherOrder);

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

说明:

锁在事务内 → 一人一单规则仍失效,锁的释放时机早于事务提交时机,导致 "一人一单" 规则被突破。

@Transactional 注解的方法,其事务提交时机是「整个方法执行完毕、返回结果后」,而 synchronized 锁的释放时机是「代码块执行完毕」。

② 真实执行时序(以同一个用户的两个并发请求为例):

bash 复制代码
线程A(用户1)执行步骤:
1. 获取用户1的锁 → 2. 查订单(count=0)→ 3. 扣库存 → 4. 创订单 → 5. 释放锁(synchronized代码块执行完)→ 6. Spring提交事务(订单真正入库)

线程B(用户1,线程A释放锁后立即执行):
1. 获取用户1的锁 → 2. 查订单(此时线程A的事务还没提交,数据库中无订单,count=0)→ 3. 重复下单 → 4. 释放锁 → 5. Spring提交事务

最终结果:用户 1 会创建两个订单,"一人一单" 的核心规则完全失效。

(3) 版本3.0
java 复制代码
synchronized (userId.toString().intern()) {
    // 1. 获取 Spring 的代理对象
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    // 2. 通过代理对象调用
    return proxy.createVoucherOrder(voucherId);
}

说明:

  • 保证事务生效 :通过 proxy 调用,Spring 代理对象会拦截方法,开启事务、提交 / 回滚,数据一致性得到保证。
  • 保证一人一单 :锁还在外层,释放锁前事务一定提交,并发安全性得到保证。
6. 集群环境下的并发问题

同一个用户(比如用户 100),同时发送请求到 服务器 A服务器 B

  • 服务器 A 拿到锁,正在查库存 → 库存充足 → 准备扣减。
  • 服务器 B 拿到锁,正在查库存 → 库存充足 → 准备扣减。
  • 原因:服务器 A 的锁只认识服务器 A 里的线程,它根本不知道服务器 B 里还有个线程在抢同一个资源。
  • 结果两个服务器同时执行了扣减逻辑 ,导致了 超卖 ,且同一个用户买了 两份订单(一人多单)。

二、分布式锁

1. Redis 实现分布式锁版统一
(1) SimpleRedisLock
java 复制代码
private static final String KEY_PREFIX="lock:"
@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);
}

说明:

① .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);

1). setIfAbsent 对应 Redis 的 SETNX 命令,核心逻辑是:如果 key 不存在,则设置值并返回 true;如果 key 已存在,则直接返回 false------ 这是实现分布式锁 "互斥性" 的核心(保证同一时间只有一个线程能拿到锁)。

2). threadId + "" 的小细节,Thread.currentThread().getId() 返回的是 long 原始类型 ,而 Redis 操作的 value 必须是 String 类型。拼接空字符串是最简洁的语法糖,能将 long 强制转为 String,满足 setIfAbsent 的参数类型要求。等价效果:String.valueOf(threadId),但写法更简洁。

② 为什么自动拆箱有风险?Boolean.TRUE.equals(success) 好在哪?

1). 代码中 Boolean success = ... 定义的是包装类 Boolean 对象 ,如果直接写 return success;,会触发 自动拆箱 (将 Boolean 转为 boolean 原始类型)。

  • 当 Redis 操作异常 / 返回 null 时,success 会是 null
  • 此时拆箱过程会尝试把 null 转为 boolean,直接抛出 NullPointerException,导致程序崩溃。

2). Boolean.TRUE.equals(success) 的核心优势

  • Boolean.TRUE 是 Boolean 类的单例常量,调用其 equals() 方法时,内部自带 null 校验逻辑
  • 无论 successnullBoolean.FALSE 还是 Boolean.TRUE,都能安全返回结果:
success 取值 return success;(直接拆箱) return Boolean.TRUE.equals(success);
null NullPointerException 返回 false
Boolean.FALSE NullPointerException 返回 false
Boolean.TRUE 正常返回 true 返回 true
(2) 修改业务代码
java 复制代码
  @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);
        //获取锁对象
        boolean isLock = lock.tryLock(1200);
		//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
    }
2. Redis分布式锁误删情况说明
(1) 存在的问题
  • 线程 1成功获取锁,开始执行业务
  • 线程 1 遇到阻塞 (比如 JVM GC 垃圾回收、CPU 繁忙),导致业务执行超时 → Redis 锁自动过期释放
  • 线程 2 刚好来抢锁,成功获取锁,开始执行自己的业务。
  • 线程 1恢复执行,业务做完了,它不知道锁已经过期被线程 2 拿走了,直接执行「删除锁」逻辑 → 误删线程 2 的锁。
(2) 解决方案

① 核心逻辑:在存入锁时,放入自己线程的标识。在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。

② 代码实现

加锁

java 复制代码
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
   // 获取线程标示
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 获取锁
   Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
   return Boolean.TRUE.equals(success);
}

释放锁

java 复制代码
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. 分布式锁的原子性问题

我们结合线程 1、线程 2 的动作,一步步拆解:

时间节点 线程 1 动作 线程 2 动作 Redis 锁状态 关键变化
1 持有锁(value = 线程 1ID),执行业务 阻塞等待锁 存在(属于线程 1) 线程 1 正常拿锁
2 执行到解锁步骤,先get锁,发现 value 是自己的 ID,进入if判断 等待 存在(属于线程 1) 线程 1 完成 "验证",还没执行 delete
3 线程 1 突然卡顿 / GC(CPU 切换) 锁过期自动释放 致命点!锁因为超时被 Redis 回收,线程 1 还没删
4 卡顿中 尝试抢锁,成功获取锁(value = 线程 2ID),开始执行业务 存在(属于线程 2) 线程 2 拿到了锁,业务正在跑
5 线程 1 恢复执行 执行业务中 存在(属于线程 2) 线程 1 从if里继续往下走,直接执行delete
6 完成删除 业务中 被删空 线程 1 把线程 2 的锁删了!线程 2 的业务还没做完,锁没了

核心后果 :线程 1 明明做了 "验证归属" 的判断,却依然误删了线程 2 的锁 ------ 因为验证和删除中间被打断了。

4. 利用Java代码调用Lua脚本改造分布式锁
java 复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~

说明:

① 静态代码块:加载 Lua 脚本

  • static final:脚本只需要加载一次,类加载时初始化,高效复用
  • DefaultRedisScript:Spring Data Redis 提供的封装类,用来和 Redis 模板配合执行 Lua 脚本
  • 脚本位置:unlock.lua 放在项目 resources 文件夹下,里面就是 "判断 + 删除" 的逻辑

② unlock () 方法:执行 Lua 脚本释放锁

  • 参数 1:UNLOCK_SCRIPT → 刚才加载好的脚本对象
  • 参数 2:Collections.singletonList(KEY_PREFIX + name) → 脚本里的 KEYS[1],就是锁的 key(比如 lock:order
  • 参数 3:ID_PREFIX + Thread.currentThread().getId() → 脚本里的 ARGV[1],就是当前线程的唯一标识(用来和锁里存的 value 对比)
相关推荐
wjm0410062 小时前
ios学习路线-- swift基础2
学习·ios·swift
科技林总2 小时前
【系统分析师】第12章 软件架构设计
学习
北岛寒沫2 小时前
北京大学国家发展研究员 中国经济专题 课程笔记(第二课 农村土地改革)
经验分享·笔记·学习
Piccab0o2 小时前
【学习笔记】——电磁相关
笔记·学习
boy快快长大3 小时前
【PyTorch】2.0 入门学习
人工智能·pytorch·学习
愚者游世3 小时前
Qt 基础认知
c++·学习·程序人生·职场和发展·visual studio
youyoulg3 小时前
监督学习-回归
学习·数据挖掘·回归
WangJunXiang63 小时前
nginx安全笔记
笔记·nginx·安全
不只会拍照的程序猿3 小时前
《嵌入式AI筑基笔记02:Python数据类型02,从C的“硬核”到Python的“包容”》
开发语言·笔记·python