黑马点评中的分布式锁设计与实现(Redis + Redisson)

黑马点评中的分布式锁设计与实现(Redis + Redisson)

在高并发秒杀场景中,分布式锁是保证"一人一单、库存不超卖"的核心手段之一

黑马点评中一人一单单机部署 通过锁实现的解析博客内容:黑马点评中 VoucherOrderServiceImpl 实现类中的一人一单实现解析(单机部署)

📚 目录(点击跳转对应章节)

[1. 为什么秒杀系统必须使用分布式锁?](#1. 为什么秒杀系统必须使用分布式锁?)
[2. 分布式锁在本项目中的作用点](#2. 分布式锁在本项目中的作用点)
[3. 方案一:基于 Redis 的自定义分布式锁(SimpleRedisLock)](#3. 方案一:基于 Redis 的自定义分布式锁(SimpleRedisLock))
[4. 方案二:使用 Redisson 实现分布式锁(推荐)](#4. 方案二:使用 Redisson 实现分布式锁(推荐))
[5. 两种分布式锁方案对比](#5. 两种分布式锁方案对比)
[6. 总结](#6. 总结)


一、为什么秒杀系统必须使用分布式锁?

在秒杀场景中,系统通常具备以下特征:

  • 并发极高(瞬时成千上万请求)
  • 库存极少
  • 严格的一人一单规则

如果不加锁,会出现以下问题:

1. 超卖问题

多个线程同时读取库存 stock > 0,然后同时扣减,导致库存变成负数。

问题本质说明:

超卖的根本原因并不是代码写错,而是:

库存判断与库存扣减不是一个原子操作

即使使用了数据库事务,在高并发写场景下:

  • MySQL 默认隔离级别为 REPEATABLE READ
  • 多个事务可能同时读取到相同库存值
  • 仅依赖数据库事务会导致大量锁竞争,性能急剧下降

因此,在秒杀系统中:

  • 锁必须前置到业务逻辑之前
  • 不能依赖数据库作为第一道并发控制防线

2. 一人多单问题

同一用户在极短时间内发送多个请求,绕过数据库层面的校验。

虽然可以在数据库中通过 (user_id, voucher_id) 唯一索引兜底,但这种方式存在明显问题:

  • 唯一索引只能保证最终一致性
  • 并发请求仍然会打到数据库
  • 产生大量无效 insert、回滚和异常日志

分布式锁的价值在于:

在请求进入数据库之前,就直接拦截无效并发请求,从源头削峰


3. 单机锁失效

synchronizedReentrantLock 只能保证单 JVM 生效,在集群环境下完全无效。

在秒杀系统中:

  • 服务通常是多实例部署
  • 请求会被负载均衡分发到不同 JVM

这类锁无法跨进程、跨机器生效,因此必须使用分布式锁。


二、分布式锁在本项目中的作用点

seckillVoucher() 方法中,分布式锁的作用是:

保证同一个秒杀券,在同一时刻只能被一个线程创建订单

关键代码位置:

java 复制代码
RLock lock = redissonClient.getLock(RedisConstants.LOCK_ORDER_KEY + voucherId);
boolean isLock = lock.tryLock();

锁设计说明补充

  • 锁粒度:voucherId
  • 锁范围:下单逻辑整体
  • 锁目的:防止并发创建订单导致的一人多单与库存竞争

这种设计属于偏保守但极其安全的方案:

  • 同一券严格串行下单
  • 不同券互不影响
  • 逻辑清晰,适合生产系统和教学项目

三、方案一:基于 Redis 的自定义分布式锁(SimpleRedisLock)

1. 核心思想

使用 Redis 的:

复制代码
SETNX + 过期时间 + Lua 脚本

来保证:

  • 加锁的原子性
  • 不误删他人锁
  • 防止死锁

补充说明:

  • SETNX\](file:///d:/CodingFiles/MarkDown/c.md#L15-L40) 保证只有一个线程能成功设置锁

  • Lua 脚本保证解锁操作的原子性

2. 自定义锁接口

java 复制代码
public interface ILock {
    boolean tryLock(Long timeoutSec);
    void unlock();
}

接口设计刻意保持简洁:

  • 只关注最核心的加锁与释放
  • 方便后续替换不同实现

3. SimpleRedisLock 完整实现

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 StringRedisTemplate stringRedisTemplate;
    private String name;

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    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 SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    @Override
    public boolean tryLock(Long timeoutSec) {
        String threadId = ID_PREFIX + Thread.currentThread().threadId();
        String key = KEY_PREFIX + name;
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().threadId()
        );
    }
}

4. 为什么释放锁一定要用 Lua?

如果使用 Java 代码:

java 复制代码
if (threadId.equals(redisValue)) {
    delete(key);
}

这并不是原子操作,可能发生:

  1. 判断锁归属成功
  2. 线程发生上下文切换
  3. 锁过期并被其他线程获取
  4. 当前线程误删他人锁

Lua 脚本可以保证:

判断锁归属 + 删除锁 是一个不可分割的原子操作


5. 自定义分布式锁的局限性补充

该实现仍然存在以下问题:

  • 不支持可重入
  • 无自动续期机制
  • 业务执行时间超过锁 TTL 会导致锁提前释放

因此更适合作为:

  • 原理学习
  • 面试讲解
  • 简单业务场景

四、方案二:使用 Redisson 实现分布式锁(推荐)

1. Redisson 的优势

  • 内置 Watch Dog 自动续期机制
  • 支持可重入锁
  • 支持公平锁、读写锁、联锁
  • 实现成熟,经过大量生产验证

Redisson 本质上是:

对 Redis 分布式能力的一层高质量工程封装


2. Redisson 配置类

java 复制代码
@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private String port;

    @Value("${spring.data.redis.password}")
    private String password;

    @Value("${spring.data.redis.database:2}")
    private String database;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        String address = "redis://" + host + ":" + port;
        config.useSingleServer()
                .setAddress(address)
                .setPassword(password)
                .setDatabase(Integer.parseInt(database));
        return Redisson.create(config);
    }
}

3. 秒杀中使用 Redisson 锁

java 复制代码
RLock lock = redissonClient.getLock(RedisConstants.LOCK_ORDER_KEY + voucherId);

boolean isLock = lock.tryLock();
if (!isLock) {
    return Result.fail("不允许重复下单");
}

try {
    IVoucherOrderService proxy =
            (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(userId, voucherId);
} finally {
    lock.unlock();
}

补充说明:

  • tryLock() 默认不阻塞,符合秒杀快速失败的业务语义
  • finally 块释放锁,防止异常导致死锁
  • Watch Dog 会在业务执行期间自动续期

4. 为什么锁要放在事务外?

分布式锁与事务职责不同:

  • 分布式锁:控制并发入口
  • 事务:保证数据一致性

如果锁放在事务内,可能出现:

  • 事务尚未提交
  • 锁已经释放
  • 其他线程读取到旧数据

正确顺序:

先加分布式锁 → 再进入事务 → 提交事务 → 最后释放锁


五、两种分布式锁方案对比

对比项 SimpleRedisLock Redisson
实现难度 较高 较低
安全性 较高 很高
自动续期 不支持 支持
可重入 不支持 支持
生产推荐

六、总结

  • 秒杀系统中,分布式锁是必不可少的基础设施
  • 自定义 Redis 锁有助于深入理解底层原理
  • 生产环境应优先选择 Redisson 等成熟方案
  • 锁负责并发控制,事务负责数据一致性,二者边界必须清晰

真正高并发系统的核心,不在于代码技巧,而在于对并发、原子性和时序问题的深入理解

相关推荐
码界奇点2 小时前
基于SpringBoot与Shiro的细粒度动态权限管理系统设计与实现
java·spring boot·后端·spring·毕业设计·源代码管理
小毅&Nora2 小时前
【Java线程安全实战】⑬ volatile的奥秘:从“共享冰箱“到内存可见性的终极解析
java·多线程·volatile
Yu_Lijing2 小时前
基于C++的《Head First设计模式》笔记——适配器模式
c++·笔记·设计模式
亓才孓2 小时前
Java第三代时间API
java·开发语言
老邓计算机毕设2 小时前
SSM新华书店o2o服务系统89nml(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·客户管理·ssm 框架·新华书店 o2o·书籍管理
码农水水2 小时前
京东Java面试被问:Spring Boot嵌入式容器的启动和端口绑定原理
java·开发语言·人工智能·spring boot·面试·职场和发展·php
岁岁种桃花儿2 小时前
深入理解MySQL SELECT语句执行顺序:从书写到内部流程全解析
数据库·mysql
咒法师无翅鱼2 小时前
【西电机器学习】学习笔记(基础部分)
笔记·学习
摸鱼的春哥2 小时前
继续AI编排实战:带截图的连麦切片文章生成
前端·javascript·后端