实战篇Redis

基于Redis实现短信登录

流程分析

生成验证码

  1. 接受前端发送的数据,后端进入验证码发送流程
  2. 检验手机格式
  3. 若正确,随机生成验证码
  4. 将验证码存于Redis中

登录

  1. 用户填入验证码后发送请求,后端进入登录流程
  2. 检验手机号格式
  3. 利用手机号从Redis获取相关联的验证码
  4. 检验验证码是否和用户输入的验证码是否相同
  5. 相同,则查询是否存在账户,若无,则创建一个新用户
  6. 若有,则将用户的信息封装为DTO存入Redis中
  7. 生成token,用于验证用户
  8. 返回token给前端

拦截器

  1. 获取请求头中的tokenb
  2. 基于token获取Redis中的用户
  3. 刷新token有效期

发送短信验证码

@Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号格式
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误");
        }

        // 2.生成验证码
        String code = RandomUtil.randomNumbers(6);

        // 3.将验证码存入Redis
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        
        // 4.发送验证码
        log.debug("发送短信验证码成功,手机号:{},验证码:{}", phone, code);

        return Result.ok("验证码已发送");
    }

检验手机号格式,调用RegexUtils下的isPhoneInvalid函数,对应代码为

/**
  * 是否是无效手机格式
  * @param phone 要校验的手机号
  * @return true:符合,false:不符合
  */
public static boolean isPhoneInvalid(String phone){
    return mismatch(phone, RegexPatterns.PHONE_REGEX);
}

// 校验是否不符合正则格式
private static boolean mismatch(String str, String regex){
    if (StrUtil.isBlank(str)) {
        return true;
    }
    return !str.matches(regex);
}

RegexPatterns.PHONE_REGEX为对应的正则表达式,mismatch函数先判断该字符串是否为空,若为空,则不符合正则格式,返回true

matches是 Java 中 String 类的一个方法,用于判断字符串是否与给定的正则表达式模式匹配

发送业务功能暂未实现,先模拟输出log

短信验证码登录

从Redis获取验证码并检验

String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
    return Result.fail("验证码错误");
}

调用stringRedisTemplate中的opsForValue()获取验证码,若不一致,则报错

存储获取到的用户信息

User user = query().eq("phone", phone).one();
        // 不存在,创造新用户并保存
        if (user == null) {
            user = createUserWithPhone(phone);
        }

        // 将User对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

因为stringRedisTemplate我们所用的为其中对String类型操作的函数,而DTO变量类型不统一,直接传递会报错,所以需要先转换

BeanUtil.beanToMap() 将Java Bean对象的属性转换为键值对(Key-Value)形式的Map

CopyOptions.create()用来自定义转换规则的配置

setIgnoreNullValue(true)忽略源对象中值为null的属性,这些属性不会放入Map中

setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()) 为对所有非空属性值进行统一处理,将其转换为字符串

生成Token并存储

String token = UUID.randomUUID().toString(true);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);

这样,我们只需要有token,就可以从数据库中找到相关联的用户信息

拦截器

拦截器我们需要配置两项

一项在任意页面中,用来获取token,查询是否能找到相应数据,若能找到,则保存在ThreadLocal,并放行

另一项则只在需要登陆的路径中拦截,如果能从ThreadLocal中查询到相关数据,则放行

配置拦截器

所有拦截器

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于TOKEN获取redis中的用户
        String key = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

登录拦截器

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

注册拦截器

public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login")
                .order(1);
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

拦截器如果是自己new出来的(如LoginInterceptor),Spring容器不会管理它,因此无法通过@Autowired或@Resource等注解实现依赖注入,因此若在拦截器内部使用@Resource,注解会被忽略,导致注入失败

MvcConfig 是Spring管理的配置类(含 @Configuration 注解),其字段可通过 @Resource 正常注入

通过构造函数传参是少数可行的依赖注入方式之一,同时StringRedisTemplate 作为单例Bean,通过配置类注入可被多个拦截器复用,避免重复创建

店铺查询缓存

流程分析

添加店铺缓存

  1. 收到用户端发来的请求后,先从Redis中读取缓存
  2. 若读取到,则直接返回
  3. 若未读取到,在数据库中查找对应店铺
  4. 若读取到对应店铺,则将数据存在Redis中,否则返回错误

实现商铺和缓存数据双写一致

  1. 收到用户修改店铺请求后,先修改数据库
  2. 再修改好数据库后,删除缓存

解决缓存穿透

若未从缓存中查询到数据,且数据库中也未数据中查询到,就将一个空值写入到Redis中

解决缓存击穿

  1. 根据前端请求从Redis中查询缓存
  2. 若未查询到,则返回空
  3. 若查询到,判断缓存是否过期
  4. 若未过期,直接返回商品信息
  5. 若过期了,判断是否有互斥锁,若已有,返回旧信息
  6. 若无,开启独立线程,根据id查询数据库,将数据写入Redis
  7. 释放互斥锁

添加店铺缓存

public Shop queryById(Long id) {
   String key = CACHE_SHOP_KEY + id;
    //从Redis查询缓存
   String json = stringRedisTemplate.opsForValue().get(key);
    //存在则直接返回
   if (StrUtil.isNotBlank(json)) {
     Shop shop=JSONUtil.toBean(shopJson,Shop.class);
   }
   //不存在,则根据id查询数据库 
   Shop shop=getById(id);
   if (shop==null) {
     return Result.fail("店铺不存在");
   }
   //存在,则写入Redis
   stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
   return Result.ok(shop);
}

stringRedisTemplate 操作的是字符串类型的数据,当程序调用 stringRedisTemplate.opsForValue().get(key) 时,返回的是 String 类型的JSON字符串(例如:{"id":1,"name":"Shop A"}),业务逻辑中需要操作的是 Shop 类的实例(如访问其属性 shop.getName()),而非原始字符串,因此必须将JSON字符串转换为Java对象,否则无法直接使用

序列化(写入Redis时): 通过 JSONUtil.toJsonStr(shop)Shop 对象转为JSON字符串,以便存入Redis(例如:stringRedisTemplate.set(key, JSON字符串)

反序列化(读取Redis时): 通过 JSONUtil.toBean(shopJson, Shop.class) 将JSON字符串还原为 Shop 对象,使程序能直接操作对象的属性和方法

实现商铺和缓存与数据库双写一致

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:

用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案

Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
  • 先操作缓存还是先操作数据库?

    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存

若先删除缓存,再操作数据库,当两个线程并发时,线程1把缓存删了后,线程2查询缓存为空,查询数据库,写入的为旧数据

public Result updata(Shop shop) {
    Long id = shop.getId();
    if (id == null) {
        return Result.fail("店铺ID不能为空");
    }
    updateById(shop);
    stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}

当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库

当我们客户端不存在数据时,请求会访问到数据库,但是和数据库中也没有数据,这个数据穿透了缓存,直击数据库

数据库承载能力没有Redis那么高,所以在高并发请求下容易出问题

解决方案是如果不存在,就返回一个空值,这个空值有效期比较短,这样,短时间内下次访问这个不存在的数据时,redis就能找到这个数据,不会进入数据库中

public Shop queryWithPassThrough(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //查询Redis
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //若不为空,直接返回
    if (StrUtil.isNotBlank(shopJson)) {
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    if (shopJson != null) {
        return null;
    }
    // 数据库查询
    Shop shop = getById(id);
    if (shop == null) {
        //若数据库为空,将空值存入Redis中
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //若数据库不为空,存入缓存
    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    return shop;
}

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了

但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

互斥锁

因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。

假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑

假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

//获取锁
private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

//解锁
private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

操作代码:

public Shop queryWithMutex(Long id)  {
        String key = CACHE_SHOP_KEY + id;
        // 1、从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get("key");
        // 2、判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的值是否是空值
        if (shopJson != null) {
            //返回一个错误信息
            return null;
        }
        // 4.实现缓存重构
        //4.1 获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2 判断否获取成功
            if(!isLock){
                //4.3 失败,则休眠重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //4.4 成功,根据id查询数据库
             shop = getById(id);
            // 5.不存在,返回错误
            if(shop == null){
                 //将空值写入redis
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                //返回错误信息
                return null;
            }
            //6.写入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);

        }catch (Exception e){
            throw new RuntimeException(e);
        }
        finally {
            //7.释放互斥锁
            unlock(lockKey);
        }
        return shop;
    }

核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true

如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false

我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程

逻辑过期

我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题

我们把过期时间设置在 redis的value中,这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理

假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,开启一个线程去进行以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁,而线程1直接进行返回

假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据

只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

新建一个实体类,用来存储逻辑过期数据

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
    String key = CACHE_SHOP_KEY + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isBlank(json)) {
        // 3.存在,直接返回
        return null;
    }
    // 4.命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return shop;
    }
    // 5.2.已过期,需要缓存重建
    // 6.缓存重建
    // 6.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (isLock){
        CACHE_REBUILD_EXECUTOR.submit( ()->{

            try{
                //重建缓存
                this.saveShop2Redis(id,20L);
            }catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return shop;
}
  1. 当用户开始查询redis时,判断是否命中
  2. 如果没有命中则直接返回空数据,不查询数据库
  3. 而一旦命中后,将value取出,判断value中的过期时间是否满足
  4. 如果没有过期,则直接返回redis中的数据
  5. 如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁

封装Redis工具类

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间

public void set(String key, Object value, Long time, TimeUnit unit) {
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}

方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
    // 设置逻辑过期
    RedisData redisData = new RedisData();
    redisData.setData(value);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
    // 写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题

public <R,ID> R queryWithPassThrough(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(json)) {
        // 3.存在,直接返回
        return JSONUtil.toBean(json, type);
    }
    // 判断命中的是否是空值
    if (json != null) {
        // 返回一个错误信息
        return null;
    }
    // 4.不存在,根据id查询数据库
    R r = dbFallback.apply(id);
    // 5.不存在,返回错误
    if (r == null) {
        // 将空值写入redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return null;
    }
    // 6.存在,写入redis
    this.set(key, r, time, unit);
    return r;
}

方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.存在,直接返回
        return JSONUtil.toBean(shopJson, type);
    }
    // 判断命中的是否是空值
    if (shopJson != null) {
        // 返回一个错误信息
        return null;
    }

    // 4.实现缓存重建
    // 4.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    R r = null;
    try {
        boolean isLock = tryLock(lockKey);
        // 4.2.判断是否获取成功
        if (!isLock) {
            // 4.3.获取锁失败,休眠并重试
            Thread.sleep(50);
            return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
        }
        // 4.4.获取锁成功,根据id查询数据库
        r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally {
        // 7.释放锁
        unlock(lockKey);
    }
    // 8.返回
    return r;
}

优惠券秒杀

优惠券全局唯一ID生成

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

ID的组成部分:符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1753724880L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    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"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

now.toEpochSecond()是将now转换为时间戳

若直接调用 now.toEpochSecond()(无参数),系统会默认使用本地时区(如 +08:00)计算时间戳

例如,北京时间 2025-07-29T10:00:00 会被视为 UTC+8 时间,其 UTC 时间实为 2025-07-29T02:00:00Z

这样会导致时间戳减少 28,800 秒(8小时),ZoneOffset.UTC设置时区能解决这个问题

添加优惠券

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

1653365145124

tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段

新增普通卷

@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}

新增秒杀卷代码

VoucherController

@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());
}

VoucherServiceImpl

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到Redis中
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}

秒杀下单

使用Lua脚本解决多条命令原子性问题

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>(); //创建脚本执行器实例
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 加载Lua脚本
    SECKILL_SCRIPT.setResultType(Long.class); // 声明返回类型
}

@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) {
        // 不为0 ,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    //返回订单id
    return Result.ok(orderId);
}

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性

参数类型说明
SECKILL_SCRIPTRedisScript预加载的 Lua 脚本对象,包含秒杀业务逻辑
Collections.emptyList()ListKEYS 列表,这里为空表示脚本不需要操作特定 Key
voucherId.toString()StringARGV:优惠券 ID
userId.toString()StringARGV:用户 ID
String.valueOf(orderId)StringARGV:订单 ID

lua脚本为

local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]

local stockKey = "seckill:stock:" .. voucherId  -- 存储库存数量
local orderkey = "seckill:order:" .. voucherId  -- 存储已购买用户ID集合

if (tonumber(redis.call("get", stockKey)) <= 0) then
    return 1 -- 库存不足
end

if (redis.call("sismember", orderkey, userId) == 1) then
    return 2 -- 重复购买
end

redis.call("incrby", stockKey, -1) -- 库存-1

redis.call("sadd", orderkey, userId) -- 添加用户到购买集合
redis.call("xadd", "stream.orders", "*", "userId", userId, "voucherId", voucherId, "id", orderId)
return 0;
  • 使用redis.call("get")获取当前库存,tonumber()将字符串转为数字(Redis返回的是字符串)
  • 返回1表示库存不足,返回状态码2表示重复购买
  • 使用Redis Stream发送订单消息
  • 消息包含用户ID、优惠券ID和订单ID
  • *表示自动生成消息ID

创建订单

引入依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

配置Redisson客户端:

@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);
    }
}

注入RedissonClient

@Resource
private RedissonClient redissonClient;

private void createVoucherOrder(VoucherOrder voucherOrder) {
    Long userId = voucherOrder.getUserId();
    Long voucherId = voucherOrder.getVoucherId();
    // 创建锁对象
    RLock redisLock = redissonClient.getLock("lock:order:" + 
userId);
    // 尝试获取锁
    boolean isLock = redisLock.tryLock();
    // 判断
    if (!isLock) {
        // 获取锁失败,直接返回失败或者重试
        log.error("不允许重复下单!");
        return;
    }
    try {
        //查询订单
        int count = query().eq("user_id", userId).eq
("voucher_id", voucherId).count();
        // 判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            log.error("不允许重复下单!");
            return;
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            log.error("库存不足!");
            return;
        }
        // 创建订单
        save(voucherOrder);
    } finally {
        // 释放锁
        redisLock.unlock();
    }
}

基于Stream的消息队列

整体架构

Javaprivate static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
  1. 单线程执行器:使用 newSingleThreadExecutor() 创建单线程线程池,确保订单处理顺序性
  2. 初始化触发@PostConstruct 注解使 Spring 容器初始化后自动启动订单处理器

核心处理器

private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            // 订单处理逻辑
        }
    }
}

读取消息

List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
    Consumer.from("g1", "c1"),                      // 消费者组和消费者标识
    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), // 读取配置
    StreamOffset.create("stream.orders", ReadOffset.lastConsumed())  // 流位置
);

Consumer.from("g1", "c1")表示从消费者组g1中,以消费者c1的身份读取消息

.count(1):每次最多读取1条消息

.block(Duration.ofSeconds(2)):若无消息则阻塞等待2秒,超时返回空列表;期间有新消息立即返回

"stream.orders":目标Stream的名称(消息队列标识)

ReadOffset.lastConsumed():表示只读取该消费者组未处理的新消息(已读未ACK的消息不重复读取)

消息处理

if (list == null || list.isEmpty()) continue; // 无消息则跳过

// 消息转换
MapRecord<String, Object, Object> record = list.get(0);// 获取第一条消息
Map<Object, Object> value = record.getValue();// 提取消息内容(键值对形式)
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);// 将Map转换为Java Bean对象

createVoucherOrder(voucherOrder); // 创建订单

// ACK确认
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
  • Bean转换:使用 Hutool 的 BeanUtil 将 Redis Hash 转换为 Java 对象
  • 业务处理:执行 createVoucherOrder 订单创建逻辑
  • 消息确认:成功处理后发送 XACK 命令

stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId()) 是 Redis Stream 中消费者组(Consumer Group)确认消息处理完成的核心操作,其作用原理与参数含义如下:

参数含义示例
"s1"Stream 的名称,即消息队列的标识"stream.orders"
"g1"消费者组名称,用于管理多个消费者"order_consumers"
record.getId()消息的唯一ID(由 Redis 生成,格式如 "1692632086370-0""1692632086370-0"

通过 XACK 命令告知 Redis 服务器:指定消费者组中的消费者已成功处理该消息。确认后:

  1. 消息从“未确认列表”(Pending List)移除,避免重复投递。
  2. 📊 消费者组更新处理进度(last delivered ID),确保后续消息正确分配。

异常处理

Javacatch (Exception e) {
    log.error("处理订单异常", e);
    handlePendingList(); // 处理异常消息
}
最后修改:2025 年 08 月 01 日
如果觉得我的文章对你有用,请随意赞赏