优惠券秒杀思路,redis 分布式锁 方式

文章目录

      • VoucherOrderController.java
      • [IVoucherOrderService 及其实现类](#IVoucherOrderService 及其实现类)
      • [ILock 及其实现类](#ILock 及其实现类)
    • [lua 脚本](#lua 脚本)
  • [基于 SETNX(setIfAbsent)手写分布式锁](#基于 SETNX(setIfAbsent)手写分布式锁)
    • [问题1- 不可重入](#问题1- 不可重入)
    • [问题2- 不可重试](#问题2- 不可重试)
    • [问题3- 超时释放](#问题3- 超时释放)
    • [问题4- Redis主从一致性问题(主从异步复制锁丢失)](#问题4- Redis主从一致性问题(主从异步复制锁丢失))

VoucherOrderController.java

java 复制代码
package com.hmdp.controller;

import com.hmdp.dto.Result;
import com.hmdp.service.IVoucherOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {

    @Autowired
    private IVoucherOrderService voucherOrderService;

    // redis分布式锁 - 版本
    @PostMapping("seckill-redis/{id}")
    public Result seckillVouvherRedis(@PathVariable("id") Long voucherId) {
        return voucherOrderService.getOrderBySeckillVoucherRedis(voucherId);
    }
}

IVoucherOrderService 及其实现类

java 复制代码
package com.hmdp.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;

public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result getOrderBySeckillVoucher(Long voucherId);

    Result getOrderBySeckillVoucherRedis(Long voucherId);
}
java 复制代码
package com.hmdp.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
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.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
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 java.time.LocalDateTime;

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    private final RedisIdWorker redisIdWorker;
    private final ISeckillVoucherService seckillVoucherService;
    private final StringRedisTemplate stringRedisTemplate;

    public VoucherOrderServiceImpl(RedisIdWorker redisIdWorker, ISeckillVoucherService seckillVoucherService, StringRedisTemplate stringRedisTemplate) {
        this.redisIdWorker = redisIdWorker;
        this.seckillVoucherService = seckillVoucherService;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // redis分布式锁 - 版本
    @Override
    public Result getOrderBySeckillVoucherRedis(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        if (userId == null) {
            return Result.fail("用户未登录");
        }

        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

        if (voucher == null) {
            return Result.fail("优惠券,不存在");
        }

        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("活动,未开始");
        }

        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("活动,已结束");
        }

        if (voucher.getStock() < 1) {
            return Result.fail("优惠券,库存不足");
        }

        // 创建锁对象:key=lock:order:userId
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 获取锁,锁超时1200秒
        boolean isLock = lock.tryLock(1200);
        // 加锁失败 → 不允许重复下单
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            // todo 启动类记得开启代理暴露:`@EnableAspectJAutoProxy(exposeProxy = true)`,否则`AopContext.currentProxy()`报错。
            VoucherOrderServiceImpl proxy = (VoucherOrderServiceImpl) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        } finally {
            // 无论正常/异常,都释放锁
            lock.unlock();
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId, Long userId) {
        // todo 一人一单
        Integer orderCount = lambdaQuery()
                .eq(VoucherOrder::getUserId, userId)
                .eq(VoucherOrder::getVoucherId, voucherId)
                .count();
        if (orderCount > 0) {
            return Result.fail("一人仅限一单");
        }

        // todo 扣库存
        boolean success = seckillVoucherService.lambdaUpdate()
                .setSql("stock = stock - 1")
                .eq(SeckillVoucher::getVoucherId, voucherId)
                .gt(SeckillVoucher::getStock, 0)
                .update();
        if (!success) {
            return Result.fail("券的库存不足");
        }

        // todo 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        save(voucherOrder);

        return Result.ok(orderId);
    }
}

ILock 及其实现类

java 复制代码
package com.hmdp.utils;

public interface ILock {

    /**
     * todo 尝试获取锁
     *   @param timeoutSec 锁持有的超时时间,过期后自动释放
     *   @return true:获取锁成功;  false:获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * todo 释放锁
     */
    void unlock();
}
java 复制代码
package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock{

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        // todo 读取resources目录下的 unlock.lua文件
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    // 锁名字,业务标识 例如:"order",最终redis key = lock:order
    private String name;

    private StringRedisTemplate stringRedisTemplate;

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

    // todo 被`static final`修饰的变量,类加载时执行一次赋值,之后值永久不变。
    // 锁的key前缀
    private static final String KEY_PREFIX = "lock:";
    // toString(true), 参数true:去掉UUID中间的横线(38400000‑8cf0‑11bd‑b12e‑0800200c9a66)
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";


    @Override
    public boolean tryLock(long timeoutSec) {
        // todo 获取线程的全局唯一标识:uuid-线程longId
        String threadId = ID_PREFIX + Thread.currentThread().getId();

        // 获取锁
        // todo setIfAbsent 底层是:SET key value NX EX 10, 不存在则设置,过期时间为10秒【NX + EX 是一条原子命令】
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(success);
        /*todo
            setIfAbsent 返回类型是包装类`Boolean`,不是基础类型boolean。
            Boolean有三种可能值:
                1. Boolean.TRUE  → true,key不存在,设置成功(获取锁成功)
                2. Boolean.FALSE → false,key已存在(抢锁失败)
                3. null → Redis 通信异常、网络故障,返回 null
            如果直接写 return success;
                等价于拆箱:return success.booleanValue()
                当 success == null 的时候,直接抛出 NullPointerException 空指针异常。*/
    }

    // todo 当前 unlock() 是`get + if + delete`,非原子,会有bug
    public void unlockCopy() {
        /*
            问题:此方法,没有 Lua 脚本,原子性漏洞
                当前 unlock() 是`get + if + delete`,非原子。
                极端场景:
                    同一个用户并发多次请求的时,业务执行时间 超过 锁超时时间,
                    锁 被 Redis 过期删掉,当前线程会删掉其他线程的锁。
                解决方法:
                	unlock 改成 Lua 脚本,lua 脚本只能解决【误删别人锁】
                	lua只是保证释放锁的原子性,

            key 是 `lock:order:userId`
                同一个用户、同一把Redis key,先后出现两个不同线程持有这把锁,老线程把新线程的锁删掉。
        */
        // 获取线程的全局唯一标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 查询 redis
        String lockValue = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);

        if (threadId.equals(lockValue)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

    // 利用 lua脚本,实现原子命令
    @Override
    public void unlock() {
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name), 
                ID_PREFIX + Thread.currentThread().getId()
        );
    }
}

"singletonList():JDK工具方法,生成一个只能装 1 个元素的List集合(只读集合)"

lua 脚本

resources / unlock.lua

lua 只是保证释放锁的原子性

java 复制代码
-- 释放分布式锁脚本
-- KEYS[1]:锁的key
-- ARGV[1]:当前线程的唯一标识(UUID-线程id)
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end



基于 SETNX(setIfAbsent)手写分布式锁

底层命令:SET lock:xxx uuid‑threadId NX EX timeout

存在一下几个问题

问题1- 不可重入

现象:同一个线程,再次去获取同一把锁,直接获取锁失败。

场景:

java 复制代码
// 线程A 拿到锁 lock:order:100
lock.tryLock(30);

// 业务里面又调用另一个方法,再次尝试获取同一把锁
lock.tryLock(30); // 返回false,拿不到锁,业务直接失败
  • SETNX 只判断 key 存不存在,不判断是谁的线程。只要key已经存在,不管是不是当前线程,NX直接返回失败。
  • 手写 SimpleRedisLock 没有记录锁的持有计数,不支持重入。
  • Redisson内部用hash结构:key:{threadId:count},获取锁计数+1,释放计数‑1,实现可重入。

问题2- 不可重试

tryLock(timeoutSec) 当前你的代码逻辑:抢锁一次,失败直接false返回,不会阻塞、不会重试抢锁

java 复制代码
boolean isLock = lock.tryLock(1200);
if(!isLock){
    return Result.fail("不允许重复下单");
}
  • 当前实现:一次SET NX,不成功直接返回。没有循环、没有自旋重试。
  • 这是业务层面设计:秒杀场景,用户抢不到直接返回,不需要自旋等待。
  • 如果要实现可重试:需要while循环 + sleep自旋抢锁;但高并发下大量自旋会压垮Redis。
  • Redisson的tryLock(waitTime, leaseTime, unit),waitTime就是允许最大自旋重试等待时间。

注意:不是SETNX命令做不到重试;是你写的 SimpleRedisLock 没有做自旋重试逻辑。


问题3- 超时释放

给锁设置过期时间,防止宕机死锁。但是:业务执行时间 > 锁超时时间

时序:

  1. 线程A获取锁,EX设置10秒过期;
  2. 业务复杂,执行需要15秒;
  3. 10秒到,Redis自动把锁key删掉;
  4. 线程B拿到同一把锁;
  5. 线程A业务执行完毕,执行unlock(lua 脚本判断 value 匹配,此时A的value已经不存在,删锁无动作);

后果:两个线程同时执行业务,并发安全失效,出现超卖 / 一人多单。

  • 手写SimpleRedis锁没有看门狗续期机制。锁时间写死,过期直接释放。
  • Redisson:leaseTime = -1 开启看门狗,默认30s,每10s检查线程是否还持有锁,如果业务没执行完就刷新锁过期时间,解决超时释放。
  • 简易解决方案:人为把超时时间设置很大,但治标不治本,宕机会长时间死锁。

问题4- Redis主从一致性问题(主从异步复制锁丢失)

Redis主从架构,主节点写锁,异步同步给从节点。

时序:

  1. 客户端向 master 执行 SET NX,拿到锁;
  2. master还没来得及把锁key同步给slave从节点,master宕机;
  3. slave升级成为新的master;
  4. 新master没有锁key,其他客户端又可以再次获取锁。

结果:锁失效,多个客户端同时拿到锁。

普通主从、哨兵模式解决不了。

  • 方案1:Redisson红锁RedLock,需要多个独立master节点,半数以上节点获取锁成功才算拿到锁;资源消耗大,生产很少使用。
  • 方案2:Redis7.0 引入wait命令,写锁之后强制等待至少一个从节点同步完成,再返回客户端。

问题 SETNX手写SimpleRedisLock Redisson
可重入 ❌不支持 ✅Hash记录计数,支持重入
可重试(自旋等待) ❌只尝试一次 ✅waitTime参数支持自旋重试
锁超时释放 ❌业务超过过期时间锁丢失 ✅看门狗自动续期(leaseTime=-1)
主从切换锁丢失 ❌异步复制会丢锁 ❌普通哨兵依旧会丢;红锁可以缓解
相关推荐
xufengzhu4 小时前
Java 虚拟线程实战指南(JDK 21+)
java·虚拟线程·jdk21+
大猫和小黄5 小时前
吃透Java泛型:从原理避坑到实战落地
java·泛型
Bingo_BIG5 小时前
Java Spring框架:单表的查询、新增、修改、删除、导入、导出
java·spring·前后端
程序猿_极客5 小时前
【免费】分享一套优质的基于SSM的校园失物招领系统的设计与实现,源码+文档+视频详解(讲解)
java·mysql·ssm·课程设计·失物招领系统
白远山6 小时前
上海24小时自助健身房解决方案实战指南与经验分享
java·数据库·架构·需求分析
码兄科技6 小时前
24小时自助健身房系统软件开发实战指南:功能设计与部署方案
java·spring boot·uni-app
程序猿_极客6 小时前
【免费】分享一套优质的基于SpringBoot的扶贫助农管理系统(详解)
java·springboot·课程设计·计算机项目
2603_965148116 小时前
AI+API选品:下一代智能商务助手雏形已现
java·大数据·数据库·人工智能·数据挖掘