Day78~80(4~6)-F:\code\hm-dianping\hm-dianping-Redis秒杀
黑马点评
优惠券秒杀
全局唯一ID
设计分表查询
知识点
- Runnable任务
- CountDownLatch
- RedisIdWorker
1.Runnable任务
Runnable 是“活儿”:它只描述逻辑。
2.CountDownLatch
它的逻辑是:初始设定一个数字,每完成一个任务就减 1,只有当数字变成 0 时,被阻塞的门才会打开。
3.RedisIdWorker
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1767225600L;
/**
* 序列号的位数
*/
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"));
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPreFix + ":" + date);
//3.拼接并返回
return timestamp<<COUNT_BITS|count;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2026, 1, 1, 0, 0, 0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println("second="+second);
}
}
超卖问题
CAS法可能导致aba问题
知识点
- mysql默认加了行级锁
1.mysql默认加了行级锁
MySQL 的默认存储引擎是 InnoDB,它与早期的 MyISAM 引擎最大的区别之一就是支持行级锁(Row-Level Locking)。
以下是行级锁触发的具体逻辑:
- 什么时候会自动加行级锁?
在 InnoDB 中,你不需要手动写类似 LOCK ROW 的命令,系统会在以下场景自动处理:
• 增删改操作(DML):当你执行 INSERT、UPDATE 或 DELETE 时,MySQL 会自动给涉及到的行加上排他锁(X锁)。 在你的秒杀业务中,扣减库存的 update 语句就会自动触发这一机制。
• 显式锁定查询:
• SELECT ... FOR UPDATE:给读取的行加排他锁。
• SELECT ... LOCK IN SHARE MODE:给读取的行加共享锁(S锁)。 - 关键前提:必须通过索引
这是最容易踩坑的地方。InnoDB 的行锁是加在索引上的,而不是加在记录上的。
• 命中索引:如果你的 where 条件使用了主键或唯一索引,MySQL 就会精准锁定那一行。
• 未命中索引:如果 where 条件里的字段没有索引,MySQL 无法定位到具体的行,为了保证并发安全,它会升级为表锁(或者锁定索引树中的所有间隙),导致整个表被锁死。 - 普通 SELECT 加锁吗?
普通的 SELECT 语句(不带 for update)默认不加任何锁。 这是通过 MVCC(多版本并发控制) 实现的“快照读”。即使其他线程正在 update 某一行,你的普通 select 依然能读到数据(读的是历史版本),这样极大地提高了数据库的查询性能。
技术栈
- 乐观锁,在更新判断前先判断库存(60),也可以是判断版本号
1.乐观锁,在更新判断前先判断库存(60),也可以是判断版本号
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMappe
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
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.扣减库存(CAS法)
boolean success = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id",voucherId)
//CAS法
//.eq("stock",voucher.getStock())这个失败率太高
.gt("stock",0)//库存大于零
.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);
//7.返回订单id
return Result.ok(orderId);
}
}
一人一单
技术栈
- 悲观锁绑定查询和修改操作
- 集群模式:新增一个tomcat,ctrl+D,修改选项,Java,程序实参
1.悲观锁绑定查询和修改操作,抽取方法ctrl+alt+M是为了不让事务包裹锁使得读到脏数据;事务不能包裹锁;只有基于spring动态代理创建的类上的方法才能实现事务管理;悲观锁是把查询和修改都放在锁内;将userId设置为锁可以避免所有用户抢一把锁
package com.hmdp.service.impl;
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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IVoucherService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@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.一人一单
Long userId = UserHolder.getUser().getId();
//通过用户id拿锁,防止不同用户都拿同一把锁(如果加在方法上)
//如果没有加toString比较的是对象地址而不是具体值,加上之后会创建一个String对象,也还是不行,所以还得加上intern()
//intern()会去字符串常量找和这个值一样的对象的地址
//事务范围大于锁范围可能导致脏数据或数据不一致问题
synchronized(userId.toString().intern()) {
//拿到spring代理对象才能保证下面的事务生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//需要先在接口创建
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//5.一人一单
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.扣减库存(CAS法)
boolean success = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherId)
//CAS法
//.eq("stock",voucher.getStock())这个失败率太高
.gt("stock",0)//库存大于零
.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
voucherOrder.setUserId(userId);
//6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
需要引入依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
需要启动类加注解去暴露这个代理对象,默认不暴露
@EnableAspectJAutoProxy(exposeProxy = true)
2.集群模式:新增一个tomcat,ctrl+D,修改选项,Java,程序实参,synchronized是基于JVM监视器的,集群模式下会失效
分布式锁
技术栈
- 基于redis实现分布式锁的初级版本
- 防止将其他线程的锁删掉,所以删锁之前要先看看锁的标识是不是自己的,也就是校验线程标识
- lua脚本保证事务性
1.基于redis实现分布式锁的初级版本吗,如果trycatch在事务里面,可能异常被trycatch消化掉导致事务没有回滚
package com.hmdp.utils;
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec
* @return
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@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);//防止自动拆箱时,Boolean为null拆出来就是空指针
}
@Override
public void unlock() {
//释放锁完成
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
//5.一人一单
Long userId = UserHolder.getUser().getId();
//通过用户id拿锁,防止不同用户都拿同一把锁(如果加在方法上)
//如果没有加toString比较的是对象地址而不是具体值,加上之后会创建一个String对象,也还是不行,所以还得加上intern()
//intern()会去字符串常量找和这个值一样的对象的地址
//事务范围大于锁范围可能导致脏数据或数据不一致问题
//synchronized(userId.toString().intern()) {
//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if (!isLock){
//获取锁失败,返回错误或重试,这里是一人一单,所以返回错误
return Result.fail("不允许重复下单");
}
try {
//拿到spring代理对象才能保证下面的事务生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//需要先在接口创建
return proxy.createVoucherOrder(voucherId);
//}
} finally {
//下单失败才释放锁
lock.unlock();;
}
}
2.防止将其他线程的锁删掉,所以删锁之前要先看看锁的标识是不是自己的,也就是校验线程标识;通过UUID避免不同JVM的线程用一样的(14);存入redis的value中(25,35-37);自动拆箱:防止自动拆箱时,Boolean为null拆出来就是空指针(29)
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
//long threadId = Thread.currentThread().getId();
String threadId = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX+name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);//防止自动拆箱时,Boolean为null拆出来就是空指针
}
@Override
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.Lua脚本保证事务原子性,一行代码同时执行,判断和删除在lua脚本中执行,满足原子性
127.0.0.1:6379> EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name Rose
OK
127.0.0.1:6379> get name
"Rose"
127.0.0.1:6379> EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name heihei
OK
127.0.0.1:6379> get name
"heihei"
@Override
public void unlock() {
//调用lua脚本,存放在resource里面
//这里变成一行代码同时执行,判断和删除在lua脚本中执行,满足原子性
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId());
}
lua脚本
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Lenovo.
--- DateTime: 2026/1/12 14:58
---
-- 比较线程标识和锁中标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
-- 释放锁
return redis.call('del',KEYS[1])
end
return 0
Redisson
知识点
-
Spring Container 默认是单例模式。一旦你在配置类中通过
@Bean定义了一个对象,Spring 会确保:- 该方法只能在启动时执行一次。
- 之后你在任何地方通过
@Autowired注入这个对象,得到的都是同一个实例。 - 这完美解决了您之前担心的“静态代码块 vs 意外重复加载”的问题。
-
Redisson的逻辑设计是这样的:
- 如果你不设置
leaseTime(或者传 -1):Redisson 会认为你不知道业务执行具体需要多长时间。为了防止死锁,它会给你一个默认 30 秒的锁,并开启看门狗。看门狗每隔 10 秒(lockWatchdogTimeout / 3)帮你续期到 30 秒,直到你手动调用unlock()。 - 如果你设置了
leaseTime:Redisson 会认为你对业务执行时间有明确的预期。它会严格给你的时间来设置 Redis 的过期时间。一旦时间到了,Redis 就会自动删锁。它不会帮你自动续期,因为它遵循你“深思熟虑”后的结果。
- 如果你不设置
-
在全局系统领域,NPC问题并不是指游戏里的“非玩家角色”,而是由全局系统专家Martin Kleppmann提出的、导致循环锁失效的三大挑战:网络延迟(网络延迟)、进程暂停(进程暂停)、时钟漂移(时钟漂移)。
这是面试中的高级进阶题,也是理解整个系统复杂性的核心。
1. N:网络延迟(网络延迟)
现象:由于网络丢包、拥塞或路由器搁浅,请求在路上花费了超出预期的时间。
失效场景:
- 线程 A 请求 Redis 加锁,成功,锁有效时间 10s。
- 线程A执行完成业务,发送
DEL请求释放锁。 - 网络发生了极大的延迟,这个
DEL请求在路上花了 15s。 - 在第11s时,Redis里的锁已经自动过期了,线程B趁机拿到了锁。
- 第15秒时,线程A的释放请求到达,误删了线程B的锁。
2. P:Process Pause (进程暂停/GC) —— 最致命
这是 NPC 中最具破坏性的问题,通常由 Java 的Full GC引起。
失效场景:
- 线程A获取锁成功(TTL = 30s)。
- 发生 Full GC,Java 虚拟机进入STW (Stop-The-World)状态,所有代码停止运行。
- 线程A暂停了40s。
- 在第31s,Redis认为锁到期,自动将其删除。
- 线程B获取了锁,开始操作数据库。
- 第41s,线程A醒过来了,它以为自己还持有锁(因为它不知道自己刚才“断片”了),也开始操作数据库。
后果:两个线程同时持有锁并操作资源,一把锁完全失效。
3. C:Clock Drift(时钟漂移)
现象:不同服务器的物理时钟不是绝对同步的。即使有NTP协议,也可能发生几十毫秒的跳转。
故障场景:Redis 的过渡时间是依赖服务器本地时钟的。如果 Redis 集群中,机器 A 的时钟比机器 B 快很多。
- 锁定在A上设置了10s过渡。
- 由于时钟不一致,A认为已经过了10s,提前释放了锁。
- 而客户端根据自己的时间认为尚未解锁,继续操作。
4.解决方案:如何对抗NPC?
没有任何一种技术能够100%消除NPC(这是物理规律),但我们可以通过架构设计来规避风险:
① 引入Fencing Token(栅栏令牌)
这是解决进程暂停的标准解决方案。
- 原理:每次获取锁时,由锁服务(如ZooKeeper或数据库)生成一个全局递增的序号。
- 校验:在操作数据库时,带上这个序号。数据库端或资源服务器检查:如果当前请求的Token小于上一个已经处理的Token,则拒绝写入。
- 效果: 升级线程 A 因为 GC 启动后尝试写入,因为它的 Token 是旧的,操作会被拒绝。
② 使用Redisson看门狗(针对P)
虽然看门狗无法解决 STW 在持续期期间的问题,但它可以在正常的网络波动下延长锁定的深度,减少过渡期带来的冲突问题。
③ 数据库兜底(最终防线)
不要信任全球化锁。在核心业务(如扣减库存)上,必须完全满足数据库层面的约束:
- 乐观锁:
update ... set version = version + 1 where version = #{version} - 原子操作:
update ... set stock = stock - 1 where stock > 0
④选型建议
- Redis (Redlock):偏向高可用和性能,但在极端NPC下一致性较弱。
- ZooKeeper / Etcd:基于CP协议(一致性)。它们采用“临时节点+心跳”机制。如果发生进程暂停,ZK会在心跳超时后自动脱锁,虽然也有延迟,但比Redis基于物理时间的TTL机制更安全。
技术栈
-
Redisson的使用,先引入依赖,然后配置配置类,就可以直接使用(74,77)
-
分布式锁原理,看门狗机制(等待并定期更新ttl)
1.Redisson的使用,先引入依赖,然后配置配置类,就可以直接使用(74,77)
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
package com.hmdp.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
//配置
Config config = new Config();
//useSingleServer()单例模式
config.useSingleServer().setAddress("redis://192.168.100.128:6379").setPassword("123321");
//创建RedissonClient对象
return Redisson.create(config);
}
}
package com.hmdp.service.impl;
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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IVoucherService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
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 javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@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("库存不足!");
}
//5.一人一单
Long userId = UserHolder.getUser().getId();
//通过用户id拿锁,防止不同用户都拿同一把锁(如果加在方法上)
//如果没有加toString比较的是对象地址而不是具体值,加上之后会创建一个String对象,也还是不行,所以还得加上intern()
//intern()会去字符串常量找和这个值一样的对象的地址
//事务范围大于锁范围可能导致脏数据或数据不一致问题
//synchronized(userId.toString().intern()) {
//创建锁对象
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁
//boolean isLock = lock.tryLock(1200);
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock){
//获取锁失败,返回错误或重试,这里是一人一单,所以返回错误
return Result.fail("不允许重复下单");
}
try {
//拿到spring代理对象才能保证下面的事务生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//需要先在接口创建
return proxy.createVoucherOrder(voucherId);
//}
} finally {
//下单失败才释放锁
lock.unlock();;
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//5.一人一单
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.扣减库存(CAS法)
boolean success = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherId)
//CAS法
//.eq("stock",voucher.getStock())这个失败率太高
.gt("stock",0)//库存大于零
.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
voucherOrder.setUserId(userId);
//6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
通过记录线程,和记录获取锁次数,使用hash结构
2.分布式锁原理,看门狗机制(等待并定期更新ttl)
Redisson分布式锁原理总结
可重入:反复获得一个锁
可重试,获取失败再看看能不能重试(给定时间内)
超时续约:看门狗机制重置锁的超时时间,一定是获取锁以后才能这样
技术栈
- linux中部署多个redis
- MultiLock-redis的联锁
1.linux中部署多个redis
# 1. 定义源文件路径
ORIGIN_CONF="/usr/local/src/redis-6.2.6/redis.conf"
# 2. 确保必要的目录存在
mkdir -p /var/log/redis
mkdir -p /var/lib/redis/6379 /var/lib/redis/6380 /var/lib/redis/6381
# 3. 循环创建配置并启动 (6379, 6380, 6381)
for PORT in 6379 6380 6381; do
CONF_FILE="/etc/redis_${PORT}.conf"
# 复制原始配置
cp $ORIGIN_CONF $CONF_FILE
# 使用 sed 修改关键参数
sed -i "s/^port .*/port $PORT/" $CONF_FILE
sed -i "s/^bind .*/bind 0.0.0.0/" $CONF_FILE
sed -i "s/^protected-mode .*/protected-mode no/" $CONF_FILE
sed -i "s|^dir .*|dir /var/lib/redis/$PORT|" $CONF_FILE
sed -i "s|^pidfile .*|pidfile /var/run/redis_$PORT.pid|" $CONF_FILE
sed -i "s|^logfile .*|logfile /var/log/redis/redis_$PORT.log|" $CONF_FILE
# 开启后台运行(非常重要,否则会卡住终端)
sed -i "s/^daemonize no/daemonize yes/" $CONF_FILE
# 启动 Redis
redis-server $CONF_FILE
echo "Redis 端口 $PORT 启动指令已下达"
done
2.MultiLock-redis的联锁
Redis秒杀优化
技术栈
- 分开操作redis和使用异步线程执行数据库操作,需要编写lua脚本保证一致性
- 要使得匿名内部类这个任务(或者其他任务或者其他方法)在项目初始化之后就启动,要使用spring提供的注解@PostConstruct
- 线程池的创建与使用,往线程池中submit任务
- 创建子线程后的threadLocal将不能拿到,代理对象也是基于threadLocal也拿不到,要通过类常量定义
1.分开操作redis和使用异步线程执行数据库操作,需要编写lua脚本保证一致性,通过静态代码块加载lua脚本
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Lenovo.
--- DateTime: 2026/1/13 13:33
---
--1.参数列表
--1.1.优惠券id
local voucherId = ARGV[1]
--1.2.用户id
local userId = ARGV[2]
--2.数据key
--2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
--3.脚本业务
--3.1.判断库存是否充足
if (tonumber(redis.call('get',stockKey)) <= 0) then
--3.2库存不足,返回1
return 1
end
--3.2.判断用户是否下单
if (tonumber(redis.call('sismember',orderKey,userId)) == 1) then
--3.3存在,说明是重复下单,返回2
return 2
end
--3.4.扣库存
redis.call('incrby',stockKey,-1)
--3.5.保存用户
redis.call('sadd',orderKey,userId)
2.要使得匿名内部类(17~31)这个任务(或者其他任务或者其他方法)在项目初始化之后就启动,要使用spring提供的注解@PostConstruct(13),提交任务(15),通过静态代码块加载lua脚本(1-6)
3.线程池的创建与使用(10),往线程池中submit任务(15)
4.创建子线程后的threadLocal将不能拿到(34-35),代理对象也是基于threadLocal也拿不到,要通过类常量定义(66、1.3-105)
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
//创建单线程线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//要使得匿名内部类这个任务在项目初始化之后就启动
@PostConstruct//基于spring的注解,在初始化后加载这个方法
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.由于这里是新线程,没办法通过threadlocal获取用户id
Long userId = voucherOrder.getUserId();
//2.创建锁对象
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);//保证并发安全
//RLock lock1 = redissonClient.getLock("lock:order:" + userId);//保证并发安全
//RLock lock2 = redissonClient2.getLock("lock:order:" + userId);//保证并发安全
//RLock lock3 = redissonClient3.getLock("lock:order:" + userId);//保证并发安全
//3.获取锁
//boolean isLock = lock.tryLock(1200);
// 合成红锁
//RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock = lock.tryLock();
//4.判断是否获取锁成功
if (!isLock){
//获取锁失败,返回错误或重试,这里是一人一单,所以返回错误
log.error("不允许重复下单");
return;
}
try {
//拿到spring代理对象才能保证下面的事务生效,底层基于threalLocal,由于这里是基于线程池不是主线程,所以也拿不到
//IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//需要先在接口创建
proxy.createVoucherOrder(voucherOrder);
//}
} finally {
//下单失败才释放锁
lock.unlock();;
}
}
}
private IVoucherOrderService proxy;
/**
* 异步抢单
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,//传入的lua脚本
Collections.emptyList(),//传入的key的参数,KEYS[1],虽然这里没有
voucherId.toString(), userId.toString()//传入的value参数ARGV[1]和ARGV[2]
);
//2.判断结果是否为0
int r = result.intValue();
if (r != 0){
//2.1.不为0,代表没有购买资格
return Result.fail(r == 1?"库存不足":"不能重复下单");
}
//2.2.为0,有购买资格,把下单信息保存到阻塞队列
// long orderId = redisIdWorker.nextId("order");
// //TODO 保存阻塞队列
//创建订单
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.获取代理对象,放到队列里面
//拿到spring代理对象才能保证下面的事务生效,底层基于threalLocal,由于是基于线程池不是主线程,子线程所以也拿不到,只能在主线程拿到
//注入上面的成员变量,放到当前类
proxy = (IVoucherOrderService) AopContext.currentProxy();
//3.返回订单id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
//5.一人一单
//改完异步之后是子线程,不能通过threadLocal获取
//Long userId = UserHolder.getUser().getId();
Long userId = voucherOrder.getUserId();
//5.1查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder).count();
//5.2判断订单是否存在
if (count > 0) {
//用户已经购买过
log.error("用户已经购买过一次!");
//return Result.fail("用户已经购买过一次!");
return;
}
//6.扣减库存(CAS法)
boolean success = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherOrder.getVoucherId())
//CAS法
//.eq("stock",voucher.getStock())这个失败率太高
.gt("stock",0)//库存大于零
.update();
if (!success){
//扣减失败
log.error("库存不足!");
//return Result.fail("库存不足!");
return;
}
//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(voucherOrder);
save(voucherOrder);
//8.返回订单id
//return Result.ok(orderId);
}
JVM 的阻塞队列 内存限制(1024*1024) 数据安全 (服务宕机)
Redis消息队列实现异步秒杀
可能漏读:读到一条信息后处理,还没进行下一次读取的间隔中如果又添加了多于一条信息,只有最新的一条会被读取到
消费者C1,从队列S1监听的消费者组G1中,读取下一个未消费的消息,等待时间为2S
生产者通过基于stream的消息队列发送给消费者组之后,再由另一个(在这个消费组中的)消费者读取消费者组,以实现避免漏读
知识点
-
消费者组的机制:g1是监听消息队列s1,c1是g1消费者组中的一个消费者
"XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >"这句话的意思是:
“我是 消费组 g1 里的 消费者 c1,请从 消息队列 s1 中给我分配 1 个 最新(>`) 的消息,如果没有就等 2000ms。”
在 Redis Stream 的 消费者组(Consumer Group) 模式下,同一组内的多个消费者是“竞争关系”,消息只会被其中一个消费者接收到。
1. 核心机制:负载均衡(Load Balancing)
当你使用
XREADGROUP指令时,Redis 会确保:- 生产者发送一条消息到 Stream。
- 消费组收到通知。
- 组内只有一个消费者能抢到这条消息。
- 这条消息会被标记在该消费者的 PEL (Pending Entries List,待处理列表) 中,直到该消费者发送
XACK。
为什么要这么设计? 这是为了横向扩展处理能力。比如你的秒杀业务每秒有 1000 个订单消息,一个线程(消费者)处理不过来,你可以开 3 个线程都在同一个消费组里。Redis 会把 1000 条消息均匀分配给这 3 个线程,实现真正的并发处理。
2. 只有一种情况会“每一个都接收到”
只有当你定义了 “多个不同的消费组” 时,才会出现重复接收。
- 消费组 A 的消费者 1 拿到了消息。
- 消费组 B 的消费者 1 也能拿到同一条消息。 这通常用于“发布-订阅”模式,比如:一个组负责“写数据库”,另一个组负责“发推送通知”。
技术栈
- 创建消费者组时可以顺带把stream消息队列一起创建
- 直接在lua脚本中往stream消息队列里面传入订单
- 转换为String时,包装类型和基本数据类型不一样
- 从消息队列中取出,并转换为对象
1.创建消费者组时可以顺带把stream消息队列一起创建
127.0.0.1:6379> XGROUP CREATE stream.orders g1 0 MKSTREAM
2.直接在lua脚本中往stream消息队列里面传入订单(35-36)
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Lenovo.
--- DateTime: 2026/1/13 13:33
---
--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.判断库存是否充足
if (tonumber(redis.call('get',stockKey)) <= 0) then
--3.2库存不足,返回1
return 1
end
--3.2.判断用户是否下单
if (tonumber(redis.call('sismember',orderKey,userId)) == 1) then
--3.3存在,说明是重复下单,返回2
return 2
end
--3.4.扣库存
redis.call('incrby',stockKey,-1)
--3.5.保存用户
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
3.转换为String时,包装类型和基本数据类型不一样(13-17),传入消息队列(在lua里面)
/**
* 异步抢单
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//获取订单id
long orderId = redisIdWorker.nextId("order");
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,//传入的lua脚本
Collections.emptyList(),//传入的key的参数,KEYS[1],虽然这里没有
voucherId.toString(), userId.toString(),String.valueOf(orderId)//传入的value参数ARGV[1]和ARGV[2]/ARGV[3]
);
//2.判断结果是否为0
int r = result.intValue();
if (r != 0){
//2.1.不为0,代表没有购买资格
return Result.fail(r == 1?"库存不足":"不能重复下单");
}
//3.获取代理对象,放到队列里面
//拿到spring代理对象才能保证下面的事务生效,底层基于threalLocal,由于是基于线程池不是主线程,子线程所以也拿不到,只能在主线程拿到
//注入上面的成员变量,放到当前类
proxy = (IVoucherOrderService) AopContext.currentProxy();
//4.返回订单id
return Result.ok(orderId);
}
4.从消息队列中取出,并转换为对象(18-20)
- 为什么是 MapRecord<String, Object, Object>?
在 Redis Stream 中,一条消息(Record)并不是一个简单的字符串,而是一个键值对集合(Fields-Values)。
第一个 String:代表 Stream 的名称(即你的 s1 或 stream.orders)。
第二个 Object:代表 消息的 ID(即那个由 * 自动生成的,类似于 1736764800000-0 的 ID)。
第三个 Object:代表 消息的正文内容,它是一个 Map 结构。
- “怎么有三个” 是什么意思?
你提到的“三个”实际上是指你在 Lua 脚本中通过 XADD 存入的 3 个字段。
你在 Lua 脚本中执行了: redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
这导致 Redis 存储的消息内容(Map)里包含了 3 个键值对:
第一层:List (快递车) 当你执行 readGroup 时,返回的是一个 List。即便你设置了 COUNT 1,它依然是一个列表,只是里面只装了一个包裹。
list.get(0)的作用是:把这唯一的包裹从车上拿下来。 此时你手里拿到的就是MapRecord对象。
第二层:MapRecord (包裹箱) 这个对象里依然包含三样东西:
- Stream Name (寄件地址)
- RecordId (快递单号)
- Value (箱子里的货物)
private class VoucherOrderHandler implements Runnable{
String queueName = "stream.orders";
@Override
public void run() {
while (true){
try {
//1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed()));
//2.判断消息获取是否成功
if (list == null||list.isEmpty()){
//2.1.如果不存在,获取失败,继续下一次循环
continue;
}
//2.2.如果获取成功,可以下单
//3.解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
handleVoucherOrder(voucherOrder);
//3.ACK确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.error("处理订单异常",e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true){
try {
//1.获取pendingList中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0")));
//2.判断消息获取是否成功
if (list == null||list.isEmpty()){
//2.1.如果不存在,获取失败,pendingList没有异常信息
break;
}
//2.2.如果获取成功,可以下单
//3.解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
handleVoucherOrder(voucherOrder);
//3.ACK确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.error("处理pendingList订单异常",e);
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}

浙公网安备 33010602011771号