实战篇Redis

基于Redis实现短信登录
流程分析

生成验证码
- 接受前端发送的数据,后端进入验证码发送流程
- 检验手机格式
- 若正确,随机生成验证码
- 将验证码存于Redis中
登录
- 用户填入验证码后发送请求,后端进入登录流程
- 检验手机号格式
- 利用手机号从Redis获取相关联的验证码
- 检验验证码是否和用户输入的验证码是否相同
- 相同,则查询是否存在账户,若无,则创建一个新用户
- 若有,则将用户的信息封装为DTO存入Redis中
- 生成token,用于验证用户
- 返回token给前端
拦截器

- 获取请求头中的tokenb
- 基于token获取Redis中的用户
- 刷新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,通过配置类注入可被多个拦截器复用,避免重复创建
店铺查询缓存
流程分析
添加店铺缓存

- 收到用户端发来的请求后,先从Redis中读取缓存
- 若读取到,则直接返回
- 若未读取到,在数据库中查找对应店铺
- 若读取到对应店铺,则将数据存在Redis中,否则返回错误
实现商铺和缓存数据双写一致

- 收到用户修改店铺请求后,先修改数据库
- 再修改好数据库后,删除缓存
解决缓存穿透

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

- 根据前端请求从Redis中查询缓存
- 若未查询到,则返回空
- 若查询到,判断缓存是否过期
- 若未过期,直接返回商品信息
- 若过期了,判断是否有互斥锁,若已有,返回旧信息
- 若无,开启独立线程,根据id查询数据库,将数据写入Redis
- 释放互斥锁
添加店铺缓存
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;
}- 当用户开始查询redis时,判断是否命中
- 如果没有命中则直接返回空数据,不查询数据库
- 而一旦命中后,将value取出,判断value中的过期时间是否满足
- 如果没有过期,则直接返回redis中的数据
- 如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁
封装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设置时区能解决这个问题
添加优惠券
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

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_SCRIPT | RedisScript | 预加载的 Lua 脚本对象,包含秒杀业务逻辑 |
Collections.emptyList() | List | KEYS 列表,这里为空表示脚本不需要操作特定 Key |
voucherId.toString() | String | ARGV:优惠券 ID |
userId.toString() | String | ARGV:用户 ID |
String.valueOf(orderId) | String | ARGV:订单 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());
}- 单线程执行器:使用
newSingleThreadExecutor()创建单线程线程池,确保订单处理顺序性 - 初始化触发:
@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 服务器:指定消费者组中的消费者已成功处理该消息。确认后:
- 消息从“未确认列表”(Pending List)移除,避免重复投递。
- 📊 消费者组更新处理进度(last delivered ID),确保后续消息正确分配。
异常处理
Javacatch (Exception e) {
log.error("处理订单异常", e);
handlePendingList(); // 处理异常消息
}
1 条评论
(╯‵□′)╯︵┴─┴