一、分布式锁-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. 运行测试
点击顶部工具栏的绿色"启动"按钮