1.登录部分代码
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.获得验证码和手机号
String code = loginForm.getCode();
String phone = loginForm.getPhone();
//2.检验手机号格式
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("请输入正确电话号码");
}
//3.校验验证码,从redis中获得
String cachecode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
//4.验证码一致根据手机号查询用户(数据库)
if (code==null||!cachecode.equals(code)) {
return Result.fail("验证码错误,请重新输入");
}
User user = query().eq("phone",phone).one();
//5.没有则新建用户
if(user==null){
user = createUserWithPhone(phone);
}
//7.生成token作为登录令牌
String token = UUID.randomUUID().toString(true);
//8.将user转化为hash存储 将用户保存到redis里面
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//将userDTO转换成map,第三部分的参数是转换规则配置,第一个,如果为空值则不存储,避免脏数据,第2个,将userDTO所有字段的值都转为String,因为hash只能识别字符串
Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY+token,map);
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+phone,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
//9.返回token
return Result.ok(token);
}
2.UserHolder
专门用来在一次请求的全链路中存储和获取当前登录用户信息的工具类
登录——>拦截器——>UserHolder生成——>业务接口
package com.hmdp.utils;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
public class UserHolder {
//Threadlocal是java.lang包下面的类
// 为每个线程提供独立的变量副本,实现线程之间的数据隔离,避免线程安全问题
//同时还能方便的在线程的整个执行链路中共享数据
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
//识别登录用户并存储信息,而非拦截登录请求,所以无论是否拿到token,是否查到用户,最终都放行
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
//2.基于token获取redis中的用户
if (StrUtil.isBlank(token)) {
return true;
}
String userKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(userKey);
//3.判断用户是否存在
if(map.isEmpty()) {
return true;
}
//5.将查询到Hash数据转换为userDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//7.刷新有效期
stringRedisTemplate.expire(userKey,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//放行
return true;
}
3.用户签到
所用redis数据结构为bitmap,bitmap是位图,可以通过偏移量定位元素,bitmap通过最小的单位bit来进行0|1的设置,表示某个元素的值或状态,时间复杂度为O(1)。
存储非常节省空间,适合一些数据量大且使用二值统计的场景。
/**
* 签到
* @return
*/
public Result sign() {
//1.获取当前登录用户
UserDTO user = UserHolder.getUser();
if (user==null) {
return Result.fail("用户为空,无法进行签到。");
}
//2.获取当前登录用户ID
Long userId = user.getId();
//3.获取日期
LocalDateTime now = LocalDateTime.now();
//4.拼接key
//把当前年月格式化为年+月的字符串
String key = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
//拼接最终key,每人每月1个独立的签到key,避免key过大,方便按月查询
key = RedisConstants.USER_SIGN_KEY + userId + key;
//5.获取今天是这个月的第几天
int dayOfMonth = now.getDayOfMonth();
//6.写入redis,offset偏移量从0开始,所以需要减1
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
return Result.ok();
}
4.优惠券一人一单
boolean success = seckillvoucherService
.update()//开启更新
.setSql("stock=stock-1")//设置更新的sql要求
.eq("voucher_id",voucherOrder.getvoucherOrderId())//设条件,id符合
.gt("stock",0)//设置条件,库存大于0
.update();//执行更新
-
各方法的作用
方法 作用 update()创建 MyBatis-Plus 的 UpdateWrapper对象,开始构建更新条件setSql("stock=stock-1")直接设置要执行的更新 SQL 片段(而非设置固定值),实现库存自减 eq("voucher_id", ...)添加等值条件:只更新指定优惠券 ID 的记录 gt("stock", 0)添加大于条件:只有库存 > 0 时才执行更新 update()执行最终的 UPDATE SQL,返回 boolean(true = 有记录被更新,false = 无) -
实际执行的 SQL
UPDATE seckill_voucher SET stock=stock-1 WHERE voucher_id=100 AND stock>0;- 如果库存 > 0:执行扣减,返回 true(影响行数 = 1)
- 如果库存 ≤ 0:不执行扣减,返回 false(影响行数 = 0)
5.秒杀订单迭代
5.1基于线程池和阻塞队列的异步下单
私有内部类的核心作用
| 特性 | 你的代码中的体现 | 实际价值 |
|---|---|---|
| 访问权限控制 | VoucherOrderHandler 被 private 修饰,仅能在 VoucherOrderServiceImpl 内部使用 |
避免外部类调用这个仅用于异步处理的内部类,降低代码耦合 |
| 共享外部类资源 | 内部类可以直接使用外部类的 redissonClient、stringRedisTemplate 等成员变量 |
无需通过构造器传参,简化异步任务的代码编写 |
| 代码内聚 | 把「秒杀订单处理逻辑」封装在外部类内部,逻辑更集中 | 秒杀相关的核心逻辑都在 VoucherOrderServiceImpl 里,便于维护 |
补充:内部类的访问规则
private内部类:仅外部类可访问(你的场景最适合);public内部类:外部可通过「外部类。内部类」访问(如VoucherOrderServiceImpl.VoucherOrderHandler);protected/default:仅同包 / 子类可访问(极少用)。
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
//1.获取队列中的队列信息
VoucherOrder order = orderTasks.take();
//2.创建订单
handleVoucherOrder(order);
} catch (InterruptedException e) {
log.error("处理订单异常", e);
}
}
}
}
//阻塞队列,线程从中获取时,如果为空,则线程阻塞
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
核心逻辑:这是秒杀的第一代异步方案——
- 初始化一个「单线程线程池」+「容量 1024*1024 的阻塞队列」;
- 秒杀校验通过后,将
VoucherOrder对象放入阻塞队列; - 线程池中的线程不断从队列
take()任务(队列为空时线程阻塞),调用handleVoucherOrder完成下单。
核心作用:
- 脱离请求线程(Tomcat 线程),异步处理下单,避免请求线程阻塞;
- 阻塞队列起到「削峰填谷」作用,避免高并发直接打满数据库。
缺点明显:
- 阻塞队列是「内存队列」,服务重启后队列中的任务会全部丢失,导致下单失败;
- 单线程处理性能有限,无法支撑高并发秒杀;
- 没有失败重试机制,处理异常时任务直接丢弃;
- 分布式部署时,多实例的阻塞队列相互独立,无法统一管控任务。
5.2基于「Redis Stream 消息队列」的异步下单
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.orders >
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;
}
//3.解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
//4.如果获取成功,可以下单
handleVoucherOrder(voucherOrder);
//5.ACK确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
}catch (Exception e){
log.error("处理订单异常",e);
try {
handPendingList();
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
private void handPendingList() throws InterruptedException {
while (true) {
try {
//1.获取pending-list中的队列信息
//XREADGROUP GROUP g1 c1 COUNT 1 STREAMS streams.orders 0
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.如果获取失败,说明pending-list没有消息,结束循环
break;
}
//3.解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
//4.如果获取成功,可以下单
handleVoucherOrder(voucherOrder);
//5.ACK确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
}catch (Exception e){
log.error("处理pending-list订单异常",e);
Thread.sleep(20);
}
}
}
}
功能说明:
这是秒杀的第二代异步方案—— 基于 Redis Stream 实现的消息队列,解决了阻塞队列的「内存丢失」问题:
- 核心逻辑:
- 使用 Redis Stream 的「消费者组(g1)」机制,保证消息不重复消费、不丢失;
- 正常消费:通过
XREADGROUP读取 Stream 中的新消息,处理完成后ACK确认; - 异常处理:消费失败的消息会进入
pending-list,单独写handPendingList方法重试处理;
- 核心作用:
- 相比阻塞队列:支持持久化(重启不丢消息)、分布式部署(多实例消费同一个 Stream)、失败重试;
- 相比 RabbitMQ:无需额外部署中间件,复用 Redis 集群。
缺点:
- Redis Stream 的「消费者组、pending-list」需要手动维护,代码复杂度高(比如重试逻辑、ACK 确认);
- 缺乏成熟的「死信队列」机制,处理失败的消息需要自己实现重试策略;
- 性能上限受 Redis 集群限制,高并发下不如 RabbitMQ 专业;
5.3seckllVoucher旧版本
public Result seckillVoucher(Long voucherId) {
//查询用户券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断秒杀时间
//是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始!");
}
//是否结束
LocalDateTime endTime = voucher.getEndTime();
if(endTime.isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//判断库存呢是否充足
if(voucher.getStock()<=0){
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 {
//直接调用,不会触发spring aop的事务管理
//要通过代理调用,获取代理对象,才会被spring aop拦截
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
}finally {
//释放锁
lock.unlock();
}
}
这是秒杀的最原始同步方案—— 所有逻辑在 Tomcat 请求线程中完成:
- 核心逻辑:
- 先查数据库校验秒杀时间、库存;
- 用 Redisson 分布式锁保证「一人一单」;
- 调用
createVoucherOrder扣减库存、创建订单(同步执行)。
- 核心作用:
作为秒杀功能的「基础版本」,实现核心业务逻辑,但未做性能优化。
- 致命缺点:
- 高并发下,大量请求直接访问数据库,导致数据库连接池打满、响应超时;
- 库存校验在数据库层,无法保证原子性(可能超卖);
- 同步执行占用 Tomcat 线程,线程耗尽后无法处理新请求;
5.4最终版本「Lua 脚本 + 异步消息队列」
1.Spring 中加载并初始化 Redis Lua 脚本 的标准写法,目的是把秒杀场景中用于「库存校验、一人一单校验、扣减库存」的 seckill.lua 脚本加载到内存中,做成全局静态常量,避免每次执行脚本时重复加载,提升性能。
// 1. 定义一个全局静态的 Redis Lua 脚本对象,返回值类型为 Long
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 2. 静态代码块:类加载时执行,且仅执行一次(初始化脚本对象)
static {
// 3. 创建 DefaultRedisScript 实例(Spring 提供的 Lua 脚本封装类)
SECKILL_SCRIPT=new DefaultRedisScript<>();
// 4. 指定 Lua 脚本的文件路径(resources 目录下的 seckill.lua)
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
// 5. 指定 Lua 脚本的返回值类型(Long 类型,对应脚本返回的 0/1/2)
SECKILL_SCRIPT.setResultType(Long.class);
}
public Result seckillVoucher(Long voucherId) {
// 1. 获取当前登录用户的ID(从ThreadLocal中取,UserHolder是自定义工具类)
Long userId = UserHolder.getUser().getId();
// 2. 生成全局唯一的订单ID(基于Redis的雪花算法生成,避免ID重复)
long orderId = redisIdWorker.nextId("order");
// 3. 执行Lua脚本,完成核心校验(库存+一人一单)
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT, // 提前加载的Lua脚本(seckill.lua)
Collections.emptyList(), // Lua脚本的KEYS参数(空)
voucherId.toString(), // Lua脚本的ARGV[1]:优惠券ID
userId.toString(), // Lua脚本的ARGV[2]:用户ID
String.valueOf(orderId) // Lua脚本的ARGV[3]:订单ID
);
// 4. 处理Lua脚本的返回结果(避免空指针,先判空再转int)
int r = 0;
if (result != null) {
r = result.intValue();
}
// 5. 判断校验结果:非0代表无购买资格
if(r!=0){
// r=1:库存不足;r=其他(如2):重复下单
return Result.fail(r==1?"库存不足":"不能重复下单");
}
// 6. 校验通过,封装下单消息(准备发往RabbitMQ)
VoucherOrder order = new VoucherOrder();
order.setId(orderId); // 订单ID
order.setUserId(userId); // 用户ID
order.setVoucherId(voucherId); // 优惠券ID
// 7. 将订单对象转为JSON字符串(MQ消息体需序列化)
String jsonStr = JSONUtil.toJsonStr(order);
try {
// 8. 发送消息到RabbitMQ:交换机X,路由键XA,消息体jsonStr
rabbitTemplate.convertAndSend("X","XA",jsonStr );
} catch (Exception e) {
// 9. 消息发送失败的异常处理(记录日志+抛异常)
log.error("发送 RabbitMQ 消息失败,订单ID: {}", orderId, e);
throw new RuntimeException("发送消息失败");
}
// 10. 立即返回订单ID给前端(异步下单,前端可凭ID查状态)
return Result.ok(orderId);
}
-- seckill.lua 核心逻辑
-- 1. 获取入参(对应 Java 中 execute 方法的 ARGV 参数)
local voucherId = ARGV[1] -- 优惠券ID(对应 Java 的 voucherId.toString())
local userId = ARGV[2] -- 用户ID(对应 Java 的 userId.toString())
local orderId = ARGV[3] -- 订单ID(对应 Java 的 String.valueOf(orderId))
-- 2. 定义 Redis key
local stockKey = 'seckill:stock:' .. voucherId -- 库存key
local orderKey = 'seckill:order:' .. voucherId -- 订单key(记录已下单用户,防止重复)
-- 3. 校验库存
if tonumber(redis.call('get', stockKey)) <= 0 then
return 1 -- 库存不足,返回1(对应 Java 中 r=1)
end
-- 4. 校验是否重复下单(用户ID在订单key的集合中?)
if redis.call('sismember', orderKey, userId) == 1 then
return 2 -- 重复下单,返回2(对应 Java 中 r!=0)
end
-- 5. 校验通过:扣库存 + 记录用户下单
redis.call('incrby', stockKey, -1) -- 库存-1
redis.call('sadd', orderKey, userId) -- 把用户ID加入订单集合
-- 可选:把订单信息存入Stream(如果用Redis Stream的话)
-- redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'orderId', orderId)
return 0 -- 成功,返回0(对应 Java 中 r=0)
1. Lua 脚本的核心作用
为什么不用 Java 代码校验库存 / 一人一单?
Java 代码校验是「查库存→判库存→扣库存」三步,高并发下会出现竞态条件(比如两个请求同时查到库存 = 1,都扣减导致超卖);
Lua 脚本是「原子执行」:把「查库存、判一人一单、扣库存」写在一个脚本里,Redis 执行时不会被打断,彻底避免超卖 / 重复下单。
Lua 脚本返回值约定:
0:校验通过(有库存 + 未下单);1:库存不足;2:用户已下单(一人一单)。
2. 订单 ID 生成(RedisIdWorker)
- 不用数据库自增 ID:高并发下数据库自增 ID 性能低,且分布式部署时易重复;
- Redis 雪花算法:结合 Redis 的自增计数 + 时间戳 + 机器 ID,生成全局唯一、有序的订单 ID。
3. RabbitMQ 消息发送
convertAndSend("X","XA",jsonStr):"X":交换机名称(需提前在 RabbitMQ 创建);"XA":路由键(交换机根据路由键把消息转发到对应队列);jsonStr:消息体(订单信息);
- 加 try-catch:避免 MQ 宕机 / 网络问题导致请求失败,记录日志便于排查。
4. 异步下单的核心价值
为什么不直接在请求线程里创建订单?
Tomcat 的线程数有限(默认 200),如果同步下单,每个请求线程要等「查库、扣库存、创订单」完成才能释放,高并发下线程会被打满,新请求无法处理;
异步下单:请求线程只做「Redis 校验 + 发 MQ 消息」(毫秒级),立即释放,能支撑数万 QPS 的秒杀请求。
6.逻辑过期+缓存重建实现店铺页面显示
/**
* 页面店铺显示ShopServiceImpl
* @param id
* @return
* @throws InterruptedException
*/
public Result queryById(Long id) throws InterruptedException {
// 用逻辑过期解决缓存击穿
Shop shop = clientClient
.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
if (shop==null) {
Shop shop1 = this.getById(id);
if (shop1==null) {
return Result.fail("店铺不存在!");
}
else{
saveShop2Redis(shop1, CACHE_SHOP_TTL);
return Result.ok(shop1);
}
}
return Result.ok(shop);
}
//异步提交重建任务,请求线程不阻塞
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public void saveShop2Redis(Shop shop, Long expireSeconds) {
//1.查询店铺数据
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
Thread.sleep(200);
//2.封装成逻辑过期
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + shop.getId(), JSONUtil.toJsonStr(redisData));
log.info("店铺{}重建成功",shop.getId());
}catch (InterruptedException e){
log.error("缓存重建线程休眠异常",e);
throw new RuntimeException(e);
}catch (Exception e){
log.error("店铺{}重建缓存失败",shop.getId(),e);
}
});
}
线程池的核心作用
Executors.newFixedThreadPool(10) 是「固定大小线程池」,有两个核心价值:| 特性 | 作用 |
|---|---|
| 独立于 Tomcat 线程池 | 缓存重建的耗时操作不会占用 Tomcat 的请求线程; |
| 控制并发数 | 10 个线程同时重建缓存,避免瞬间大量写 Redis 导致 Redis 压力过大; |
| 线程复用 | 线程执行完一个缓存重建任务后,不会销毁,而是等待下一个任务,减少线程创建 / 销毁的开销; |
假设你的接口有 1000 个并发请求 查店铺 2(缓存为空):
同步场景(原始代码):
- 1000 个请求需要 1000 个 Tomcat 线程,但 Tomcat 只有 200 个线程,剩下 800 个请求排队;
- 每个 Tomcat 线程阻塞 200ms,200 个线程处理完第一批请求需要 200ms;
- 第二批 800 个请求继续排队,总响应时间超过 1 秒,用户体验差,甚至超时。
异步场景(优化后代码):
- 1000 个请求都由 200 个 Tomcat 线程处理,每个请求耗时 1ms,1000 个请求 5ms 内处理完;
- 1000 个缓存重建任务提交给 10 个线程池线程,每个线程处理 100 个任务,总耗时
100 * 200ms = 20秒(后台异步执行); - 所有前端请求都在 5ms 内收到响应(显示店铺信息),缓存重建在后台慢慢完成,用户无感知。
线程池总结
- 核心原理:把耗时的缓存重建操作从 Tomcat 请求线程转移到独立线程池,让 Tomcat 线程快速释放,支撑更高并发;
- 核心价值:
- 提升接口吞吐量:Tomcat 线程不再阻塞,能处理更多请求;
- 保障服务稳定性:避免 Tomcat 线程池被打满,防止服务雪崩;
- 控制后端压力:线程池限制并发重建缓存的数量,保护 Redis;
- 本质区别:同步是「一个线程干完所有活」,异步是「快活交给请求线程,慢活交给后台线程」。
浙公网安备 33010602011771号