短信登录
一、IService 的使用

上面 IService<User> 是 MyBatis-Plus 提供的一个 通用 Service 接口 。
1. IService 介绍
在 MyBatis-Plus 里,除了 BaseMapper<T> 提供了单表的 CRUD 方法外,还提供了一个 业务层的通用接口 —— IService<T>。
它封装了一整套常用的 Service 层通用方法 ,比如:
-
save``(T entity):插入一条数据 -
saveBatch``(Collection<T> entityList):批量插入 -
removeById``(Serializable id):根据 ID 删除 -
remove``(QueryWrapper<T> wrapper):条件删除 -
updateById``(T entity):根据 ID 更新 -
getById``(Serializable id):根据 ID 查询 -
list``():查询所有数据 -
page``(IPage<T> page):分页查询
👉 这些方法帮你避免了在 Service 里重复写一堆 CRUD 逻辑。
2. 使用方式
-
定义服务层接口时候,继承
IService<>public interface IUserService **extends IService<User>** { // 如果有用户特有的方法,可以在这里再定义 } -
实现类中实现服务层接口,并且继承
ServiceImpl<mapper文件名称, 实体类名称>,即可在实现类中使用上述通用方法!@Service public class UserServiceImpl **extends ServiceImpl<UserMapper, User>** implements IUserService { // UserMapper此时完成注入,可以直接用对应的接口 }
ServiceImpl<M, T>已经帮你实现了IService<T>的大部分方法,所以你只要继承它就能直接用。
3. 对比
-
BaseMapper<T>→ DAO 层(数据访问层),和数据库直接打交道。 -
IService<T>→ Service 层(业务逻辑层),封装通用业务逻辑。 -
ServiceImpl<M, T>→IService<T>的默认实现类。
- BaseMapper
-
位置 :MyBatis-Plus 提供的 Mapper 基础接口。
-
作用 :直接定义了常见的 CRUD 方法 ,例如:
-
insert(T entity) -
deleteById(Serializable id) -
updateById(T entity) -
selectById(Serializable id) -
selectList(Wrapper<T> queryWrapper)
-
-
特点 :偏向 DAO 层 ,操作数据库表。
- IService
-
位置 :MyBatis-Plus 提供的 Service 层接口。
-
作用 :在
BaseMapper的基础上,进一步封装了一些 通用业务方法 ,例如:-
save(T entity) -
removeById(Serializable id) -
updateById(T entity) -
getById(Serializable id) -
list(Wrapper<T> queryWrapper) -
page(IPage<T> page, Wrapper<T> queryWrapper)
-
-
特点 :偏向 业务层 ,避免你在 Service 里频繁写 Mapper 调用。
二、ThreadLocal 的使用
1. 概念
ThreadLocal 是 JDK 提供的一个工具类,它的核心作用是 为每个线程提供一份独立的变量副本 ,线程之间互不干扰。可以理解为“线程本地存储”。
-
普通变量:多个线程访问时会共享,可能出现并发问题。
-
ThreadLocal变量:每个线程都会维护自己的一份副本,只有当前线程能访问和修改,不会影响其他线程。
2. 内部原理
ThreadLocal 并不是把值存到自己身上,而是存在 当前线程 的 Thread 对象里:
-
每个
Thread对象有一个ThreadLocalMap属性。 -
ThreadLocal.``set``(value)时:-
拿到当前线程
Thread.currentThread() -
把键值对
(ThreadLocal实例 -> value)存到该线程的ThreadLocalMap里
-
-
ThreadLocal.``get``()时:- 也是去当前线程的
ThreadLocalMap找对应的值
- 也是去当前线程的
所以即使 ThreadLocal 是 static,不同线程各自有自己的 ThreadLocalMap,互不干扰。
3. 登录拦截中使用 ThreadLocal 的问题

封装 ThreadLocal 的接口:
public class UserHolder {
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();
}
}然后在拦截器里面存储时候调用即可:
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
*// 拿到session*
* *HttpSession session = request.getSession();
*// 拿到session中的用户信息*
* *UserDTO user = (UserDTO)session.getAttribute("user");
*// 判断用户是否存在*
* *if(user == null) {
*// 如果不存在,直接拦截*
* *response.setStatus(HttpServletResponse.*SC_UNAUTHORIZED*);
return false;
}
*// 存在的话,存放到threadlocal中,这样子后面其它拦截器使用2*
* ***UserHolder.** ***saveUser** ***(user);**
*// 放行*
* *return true;
}
}4. 为什么要用 ThreadLocal ?
-
仅限单次请求生命周期 :请求进入应用,线程处理时,把 Redis 查到的用户放进 ThreadLocal。
-
减少重复查询 :业务层、DAO 层不需要每次都再去 Redis 拿用户信息。
-
解耦传参 :避免在每个方法参数里都传
UserDTO。
ThreadLocal 解决的是 一次请求内的线程内传递 问题。
5. ThreadLocal 为什么要设为 static ?
-
为了让应用里所有地方访问的都是 同一个 ThreadLocal 实例 ,保证存取一致。
-
真正隔离的是
ThreadLocalMap存放的位置,它挂在线程上,不在ThreadLocal对象上。
三、引入 redis 实现共享 session 登录
在集群服务的情况下,多台服务器之间的 session 的一致性 是个问题,并且每台服务器都要存一份 session,一旦数据量大了,服务器的压力会非常大 ,此时我们可使用 redis 来存储 session 解决此类问题!

1. 理解 cookie、session、token 三者的区别💥
-
cookie 存放在 客户端 ,可以存放任何数据,但是容易泄漏,所以通常存放的是 sessionid,而不是直接存放用户信息。
-
session 存放在 服务端 + 客户端cookie ,一般存放用户信息(用于认证),然后据此生成 sessionid 发送给客户端,以后每次客户端访问服务器都会在 cookie 中带上该 sessionid,方便服务端验证。
- 但是 session 存在这些问题:
-
占用服务器资源
-
在分布式集群中存在一致性问题
-
依赖 cookie,所以存在跨域问题
-
sessionid 一旦泄漏,攻击者就能伪造身份进行登录,存在安全隐患
-
- 但是 session 存在这些问题:
-
token 通常存放在 客户端(localstorage 或者 header) ,是一种代表用户身份或会话的字符串凭证,本质上就是 “访问票据”。
- token 应用时候有两种类型:
-
Redis + 随机字符串 token
-
后端生成随机 ID,存到 Redis 对应用户信息
-
前端请求带 token,服务器查 Redis 获取用户数据
-
优势:服务器可随时失效 token、改善普通 session 方案直接存储在服务器中导致服务器压力过大的问题
-
-
JWT (JSON Web Token)
-
自包含 token,把用户信息和过期时间写进 token 本身,并用签名保护
-
不需要每次查 Redis,即可验证身份
-
缺点:一旦签发,服务器无法轻易强制失效(除非加黑名单)
-
-
- token 应用时候有两种类型:
2. BeanUtil 和 BeanUtils 的区别
BeanUtil 和 BeanUtils 看起来名字类似,但本质和来源不同,功能上也有差异
| 维度 | BeanUtils (Spring) | BeanUtils (Apache) | BeanUtil ** (Hutool)** |
|---|---|---|---|
| 拷贝属性 | ✅ | ✅ + 类型转换 | ✅ + 类型转换 + 高级功能 |
| Map ↔ Bean | ❌ | ❌ | ✅ |
| 忽略 null | ❌ | ❌ | ✅ |
| 性能 | 高 | 中 | 高 |
| 依赖 | Spring | Apache Commons | Hutool |
3. UserDTO 映射为 Map 后存储到 Redis 时候出现的转换错误问题
问题:如果使用 stringRedisTemplate 存储键值对,那么使用 stringRedisTemplate.``putAll``() 插入一个 HashMap 的时候,如果这个 HashMap 的键值对存在 非string 类型,那么插入时候就会出现转化失败的报错。
两种解决方法:
-
手动转换(拓展性差)
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); HashMap<String, String > userMap = new HashMap<>(); userMap.put("icon", userDTO.getIcon()); userMap.put("id", String.valueOf(userDTO.getId())); userMap.put("nickName", userDTO.getNickName()); -
使用
BeanUtil.beanToMap()方法按需转换UserDTO userDTO = new UserDTO(); BeanUtils.*copyProperties*(user, UserDTO.class); Map<String, Object> user_map = BeanUtil.*beanToMap*(userDTO, new HashMap<>(), CopyOptions.*create*() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> { if (fieldValue == null) { return null; *// 避免 NPE* * *} return fieldValue.toString(); }));
CopyOptions,这个类就是用来定制 Bean ↔ Map 拷贝规则 的,它的核心作用如下所示:
控制 null 值是否拷贝
控制 字段名映射策略 (比如驼峰转下划线,或者自定义字段映射关系)
控制 忽略的属性
控制 类型转换
CopyOptions options = CopyOptions.create() .setIgnoreNullValue(true) // 忽略 null 值 .setIgnoreError(true) // 忽略类型转换错误 .setFieldMapping(fieldMap) // 字段名映射,例如 {"userName" -> "user_name"} .setFieldNameEditor(name -> name.toLowerCase())); // 自定义字段名处理,全部转小写
四、多个拦截器配合
在之前的拦截器方案中,确实可以使用对应路径的拦截,同时刷新登录 token 令牌的存活时间,但是现在这个拦截器它只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案它是存在问题的。
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了 threadLocal 的数据,所以此时第二个拦截器只需要判断拦截器中的 user 对象是否存在即可,完成整体刷新功能。

细节:可以通过 order() 控制不同拦截器的执行优先级,否则默认先添加先执行!
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Autowired
private RefrashInterceptor refrashInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(refrashInterceptor).addPathPatterns("/**").order(0);
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/user/code")
.excludePathPatterns("/user/login")
.order(1);
}
}ThreadLocal 的清除时机
- 多个拦截器时,执行顺序和
order有关 :-
order小的 先执行preHandle, -
但是
afterCompletion是 逆序执行 。
-
换句话说:
order(0) → preHandle
order(1) → preHandle
Controller 执行
order(1) → afterCompletion
order(0) → afterCompletion- ThreadLocal 的清理时机 :
-
必须保证 只清理一次 ,并且要在所有业务逻辑用完之后。
-
最佳时机就是 最后一个相关拦截器的
afterCompletion。
-
所以根据拦截执行顺序,最后应该在 RefreshInterceptor 的 afterCompletion() 清除 ThreadLocal,防止多次删除,如下所示:
public class RefreshInterceptor implements HandlerInterceptor {
...
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 清理 ThreadLocal,防止内存泄漏
UserHolder.removeUser();
}
}商户查询缓存
一、缓存更新策略💥
缓存更新是 Redis 为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们想 Redis 插入太多数据,此时就可能会导致缓存中数据过多,所以 Redis 会对部分数据进行更新,或者把它成为淘汰更合适。
| 内存淘汰 | 超时剔除 | 主动更新 | |
|---|---|---|---|
| 说明 | 不用自己维护, 利用Redis的内存淘汰机制, 当内存不足时自动淘汰部分数据。 下次查询时更新缓存。 |
给缓存数据添加TTL时间, 到期后自动删除缓存。 下次查询时更新缓存。 |
编写业务逻辑, 在修改数据库的同时, 更新缓存。 |
| 一致性 | 差 | 一般 | 好 |
| 维护成本 | 无 | 低 | 高 |
业务场景:
-
低一致性需求 :内存淘汰机制。例如店铺类型的查询缓存(因为这个很长一段时间都不需要更新)
-
高一致性需求 :主动更新 + 超时剔除。例如店铺详情查询的缓存
1. 主动更新策略有哪些?
由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:
- 用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等
那么如何解决这个问题呢?有如下三种方式:

2. 主动更新时,数据库和缓存不一致采用什么方案

问题:先操作缓存还是先操作数据库❓❓❓
- 先删除缓存,再操作数据库:
- 删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题

- 先操作数据库,再删除缓存:
- 线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题

两者都存在线程安全问题 ,但是相对来说,后者出现线程安全问题的概率相对较低 ,所以我们最终采用后者先操作数据库,再删除缓存的方案。
二、缓存穿透💥

@Override
public Result getShopByID(Long id) {
*// 1. 从redis中查询shop缓存*
* *String cache_key = RedisConstants.*CACHE_SHOP_KEY *+ id;
String shopJson = stringRedisTemplate.opsForValue().get(cache_key);
*// 2. 存在,并且有内容,说明是真的有信息,则直接返回*
* *if (StringUtils.*hasText*(shopJson)) {
return Result.*ok*(JSONUtil.*toBean*(shopJson, Shop.class));
}
***// 存在,但是没有内容,说明是空对象,是为了避免缓存穿透的** *
* ***if(shopJson != null)** {
return Result.*fail*("商铺不存在!");
}
*// 3. 不存在,根据id查询数据库*
* *Shop shop = getById(id);
***// 4. 不存在,不是直接返回错误,而是将该id写入redis,设为空对象,设置ttl,避免缓存穿透💥** *
* *if(shop == null) {
stringRedisTemplate.opsForValue().set(
RedisConstants.*CACHE_SHOP_KEY *+ id,
**""** ,
RedisConstants.*CACHE_NULL_TTL*,
TimeUnit.*MINUTES*);
return Result.*fail*("商铺不存在!");
}
*// 5. 存在,将数据写到redis中,设置过期时间(缓存更新策略)*
* *stringRedisTemplate.opsForValue().set(cache_key, JSONUtil.*toJsonStr*(shop));
stringRedisTemplate.expire(cache_key, RedisConstants.*CACHE_SHOP_TTL*, TimeUnit.*MINUTES*);
*// 6. 返回*
* *return Result.*ok*(shop);
}三、缓存雪崩💥

四、缓存击穿💥


-
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,只是加了一把锁而已,也没有其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁的情况,就可能死锁,所以只能串行执行,性能会受到影响 -
逻辑过期方案:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构缓存数据,但是在重构数据完成之前,其他线程只能返回脏数据,且实现起来比较麻烦
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 没有额外的内存消耗 保证一致性 实现简单 |
线程需要等待,性能受影响 有死锁风险 |
| 逻辑过期 | 线程无需等待,性能较好 | 不保证一致性,读到的是脏数据 有额外内存消耗 实现复杂 |
缓存穿透 vs 缓存击穿
区别核心在有没有这个数据 和热点 key 是否存在 ,一句话区分:
-
穿透 = 缓存里没有 + 数据库也没有 。
-
击穿 = 缓存里没有 + 数据库里有(但热点 key 突然过期) 。
包装类返回成基本类型的注意事项
在以下场景中,从 Boolean 对象返回 boolean,虽然说会自动拆箱,但是会有 null 的情况,所以为了不要返回 null 的情况,最好使用 BooleanUtil.isTure()之类的方法来返回,而不是自动拆箱 !
private boolean tryLock(Long id) {
*// 为了防止线程崩了导致死锁,需要设置过期时间*
* *Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(RedisConstants.*LOCK_SHOP_KEY *+ id, "1", RedisConstants.*LOCK_SHOP_TTL*, TimeUnit.*SECONDS*);
return **BooleanUtil.** ***isTrue** ***(flag)** ;
}Object 字段与 JSON 反序列化的现象解释
问题:
一个对象中有 Object data 字段,然后序列化存放到 redis 中,再取出来的时候,重新反序列化为该对象,此时这个对象中的 data 字段是一个 JSONObject 类型,这是什么?不能直接强转为我需要的类型吗,比如说 Shop 类型,而是又要去对这个 data 再做一次 JSONUtil.toBean 才能转化为 Shop?
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
// 反序列化时候:
RedisData redisData = JSONUtil.*toBean*(shopJson, RedisData.class);
**JSONObject** data = (**JSONObject** )redisData.getData();
Shop shop = JSONUtil.*toBean*(data, Shop.class);现象解释:
-
为什么是 JSONObject
-
你原始对象里字段是
Object data。 -
序列化到 Redis 时,底层会把
data转成 JSON 字符串。 -
反序列化回来时,框架(比如
Fastjson、Hutool JSONUtil)只能推断:这是个 JSON 对象,但不知道你原本的具体类型,于是给你一个通用容器类:JSONObject。
-
-
为什么不能直接强转
-
JSONObject和你的Shop没有继承关系,强转会报ClassCastException。 -
你必须显式告诉 JSON 工具库“我要转成 Shop”,所以需要再调用一次
JSONUtil.toBean(JSONObject, Shop.class)。
-
更优写法
-
直接指明类型
- 如果你不想多一步转换,定义字段时就不要用
Object,而是用明确类型。
class Result { private Shop data; // 而不是 Object }这样反序列化时,JSON 工具能直接映射成
Shop,避免中间态的JSONObject。 - 如果你不想多一步转换,定义字段时就不要用
-
使用泛型
Shop shop1 = JSONUtil.*toBean*( shopJson, new TypeReference<Shop>() {}, // 关键:携带泛型信息 false );-
TypeReference内部通过匿名子类“捕获”泛型参数,JSON 工具就能在运行时知道T = Shop,正确映射为Shop。 -
如果不使用
TypeReference的话,同样拿到的还是JSONObject,因为泛型在编译之后存在类型擦除问题 ,不指明的话仍然是不知道类型的。
-
互斥锁实现❤️
-
因为不是逻辑过期,所以缓存以会过期,请求还是会直接打在数据库上(虽然热点问题数据库内容一定存在,不会出现太严重的穿透问题,但是最好防护一下),所以最好防止缓存穿透 。
-
在线程拿到互斥锁之后,最好进行 double check ,判断此时其它线程是否已经重建缓存了,这样子就可以减少数据库的访问了。

*// 缓存击穿(互斥锁实现方式,因为没有逻辑过期,所以会过期,请求可能直接打在数据库上,所以需要防止缓存穿透)*
private Shop getShopByBreakDownMutex(Long id) {
*// 1. 从redis中查询shop缓存(这里用string类型演示)*
* *String cache_key = RedisConstants.*CACHE_SHOP_KEY *+ id;
String shopJson = stringRedisTemplate.opsForValue().get(cache_key);
*// 2. 存在缓存,并且有内容,说明是真的有信息,则直接返回*
* *if (StringUtils.*hasText*(shopJson)) {
return JSONUtil.*toBean*(shopJson, Shop.class);
}
*// 存在缓存,但是没有内容,说明是空对象,是为了避免缓存穿透的,直接返回null*
* *if(shopJson != null) {
return null;
}
try {
***// 3. 不存在缓存,先获取锁** *
* *boolean isLock = tryLock(id);
***// 3.1 如果拿不到锁,休眠后重试** *
* *if (!isLock) {
Thread.*sleep*(50);
return getShopByBreakDownMutex(id); *// 递归去调用重试*
* *}
***// 3.2 如果拿到锁,进行doublecheck,防止后面不必要的访问数据库** *
* *String doublecheck = stringRedisTemplate.opsForValue().get(cache_key);
if(StringUtils.*hasText*(doublecheck)) {
return JSONUtil.*toBean*(doublecheck, Shop.class);
}
*// 实在没有再去查数据库*
* *Shop shop = getById(id);
*// 3.3 如果数据库中不存在,不是直接返回错误,而是将该id写入redis,设为空对象,设置ttl,避免缓存穿透💥*
* *if(shop == null) {
stringRedisTemplate.opsForValue().set(
RedisConstants.*CACHE_SHOP_KEY *+ id,
"",
RedisConstants.*CACHE_NULL_TTL*,
TimeUnit.*MINUTES*);
return null;
}
*// 3.4 数据库中存在,将数据写到redis中,设置过期时间(缓存更新策略)*
* *stringRedisTemplate.opsForValue().set(cache_key, JSONUtil.*toJsonStr*(shop), RedisConstants.*CACHE_SHOP_TTL*, TimeUnit.*MINUTES*);
return shop;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
**unlock(id)** ; // 别忘了释放锁
}
}逻辑过期实现❤️
- 这种实现方式,因为缓存是一直存在的,所以不会打到数据库中,故不需要考虑这些 key 的缓存穿透问题!

// 创建线程池
private ExecutorService executorService = Executors.*newFixedThreadPool*(10);
*// 缓存击穿(逻辑过期方式,因为缓存一直存在,所以请求不会直接打到数据库,并且数据库也有数据,所以不需要考虑缓存穿透)*
private Shop getShopByBreakDownLogicExpire(Long id) {
*// 1. 从redis中查询shop缓存*
* *String cache_key = RedisConstants.*CACHE_SHOP_KEY *+ id;
String shopJson = stringRedisTemplate.opsForValue().get(cache_key);
*// 2. 判断缓存是否命中*
* *if (!StringUtils.*hasText*(shopJson)) {
*// 3. 如果没命中,说明数据库中也没有数据,直接返回null*
* *return null;
}
***// 4. 如果命中了,拿到数据,判断缓存逻辑过期时间** *
* // (因为Object通过redis序列化之后转化为字符串,但是反序列化之后不知道类型是什么,所以用通用类型JSONObject表示)*
* *RedisData redisData = JSONUtil.*toBean*(shopJson, RedisData.class);
JSONObject data = (**JSONObject** )redisData.getData();
Shop shop = JSONUtil.*toBean*(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
***// 5. 如果没过期,直接返回数据** *
* *if(LocalDateTime.*now*().isBefore(expireTime)) {
return shop;
}
*// 6. 如果过期了,获取锁资源,判断锁资源是否拿到*
* *boolean isLock = tryLock(id);
if (isLock) {
try {
***// 7. 如果拿到锁资源,则创建新线程去重建缓存(这里用线程池)** *
* *executorService.submit(() -> {
try {
insertRedisData(id, RedisConstants.*LOCK_SHOP_TTL*);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
} finally {
**unlock(lock_key); ** ***// 解锁必须在主线程执行** *
}
}
*// 8. 返回数据*
* *return shop;
}五、封装通用接口的一些问题
-
对于通用接口,一般不能定死参数以及返回值的类型,所以需要使用泛型,并且序列化和反序列化的时候需要指定转化类型,比如
JSONUtil.toBean(jsonStr, type)接口就需要指定,所以需要在传参时候传入一个Class<T> type对象,用于指定转化类型。 -
同理,因为不同实体需要访问不同的数据库表,此时不能定死查询语句,所以需要在传参时候传入一个
Function<ID, T> dbCallBack方法对象,具体的方法由调用者提供,这里只需要在方法中调用dbCallBack.``apply``()即可调用由调用者提供的方法,完成对应实体的数据库表查询。
*// 方法3:根据指定的Key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题*
public <T, ID> T getShopPreventPenetrate(
String prefix_key,
ID id,
**Class<T> type** ,
**Function<ID, T> dbCallBack** ,
Long time, TimeUnit unit) {
*// 1. 从redis中查询缓存*
* *String key = prefix_key + id;
String jsonStr = stringRedisTemplate.opsForValue().get(key);
*// 2. 存在缓存,并且有内容,说明是真的有信息,则直接返回对象*
* *if (StringUtils.*hasText*(jsonStr)) {
try {
*// 先尝试直接转为对象*
* *T bean;
if (jsonStr.trim().startsWith("{")) {
JSONObject jsonObject = JSONUtil.*parseObj*(jsonStr);
*// 如果存在 data 字段,就取 data 部分*
* *if (jsonObject.containsKey("data")) {
bean = JSONUtil.*toBean*(jsonObject.getJSONObject("data"), type);
} else {
bean = JSONUtil.*toBean*(jsonObject, type);
}
} else {
*// 如果不是对象,而是字符串等,直接转*
* *bean = JSONUtil.*toBean*(jsonStr, type);
}
return bean;
} catch (Exception e) {
*log*.error("反序列化缓存失败,jsonStr={}", jsonStr, e);
}
}
*// 存在缓存,但是没有内容,说明是空对象,是为了避免缓存穿透的,直接返回null*
* *if(jsonStr != null) {
return null;
}
*// 3. 不存在,根据id查询数据库*
* ***T object = dbCallBack.apply(id)** ;
*// 4. 不存在,不是直接返回错误,而是将该id写入redis,设为空对象,设置ttl,避免缓存穿透💥*
* *if(object == null) {
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.*CACHE_NULL_TTL*, TimeUnit.*MINUTES*);
return null;
}
*// 5. 存在,将数据写到redis中,设置过期时间(缓存更新策略)*
* *this.set(key, JSONUtil.*toJsonStr*(object), time, unit);
return object;
}优惠券秒杀
一、Redis实现全局唯一ID💥

-
如果直接用 MySQL 自增 ID
-
优点 :简单,天然唯一,易于理解。
-
缺点 :
-
单点瓶颈 :自增主键依赖数据库,写压力集中在单点,容易撑不住
-
扩展性差 :分库分表后,自增 ID 很难保证全局唯一
-
性能低 :高并发下,频繁 insert 只为拿 ID,会拖慢数据库
-
不灵活 :无法自定义格式(如带业务前缀、时间戳)
-
-
-
Redis 生成全局唯一 ID 的优势
-
高性能
-
Redis 基于内存,
INCR/INCRBY操作是原子性的,单机 QPS 可达几十万。 -
生成 ID 基本无延迟,适合秒杀、抢券这种高并发场景。
-
-
分布式保证唯一性
-
多个服务节点访问同一个 Redis,就能保证 ID 不重复。
-
不用担心分库分表后 ID 冲突。
-
-
灵活性强
- 可以拼接时间戳、业务标识、随机数,生成带业务意义的 ID,例如:
20250927000001 // 日期 + 自增序列 CPN202509270001 // 优惠券业务 + 日期 + 序号 -
解耦数据库
-
不需要依赖 MySQL 自增键。
-
即使数据库宕机,ID 依旧能发号。
-
-
高可用
-
下面自定义 ID 的组成部分:

private final static long *beginTime *= 1735689600L; *// 设置起始时间(2025年1月1号0点0分0时)*
private final static long *OFFSET_BIT *= 32L; // 时间戳偏移量
***/**** *
*** * 创建一个全局唯一的ID,由 “符号位 + 时间戳 + 序列号” 组成** *
*** */** *
public long nextId(String prefix_key) {
*// 1. 创建时间戳*
* *LocalDateTime now = LocalDateTime.*now*();
long timeStamp = now.**toEpochSecond** (**ZoneOffset.** ***UTC** *) - *beginTime*;
*// 2. 通常increment指令,生成序列号(对于key,为了提高安全性,最好加上一个格式时间,让每天生成的序列号区分开)*
* *String date = now.**format** (**DateTimeFormatter.** ***ofPattern** *("yyyy:MM:dd"));
long serial = stringRedisTemplate.opsForValue().**increment** (**"inc:" + prefix_key + ":" + date** );
*// 3. 拼接成ID进行返回*
* *return timeStamp << *OFFSET_BIT *| serial;
}💥注意事项
- 这里在
increment序列号的时候,需要提供一个 key,这是有讲究的,不能只是用inc:prefix_key作为键,因为 Redis 中的INCR(increment)本质上就是对一个 64 位有符号整数 ,不断累加后也是会溢出的,所以可以在键中附上一个格式时间 ,比如年:月:日,这样子就能将序列号按照日期分开了 ,理论上就不会有溢出风险了(因为 64 位有符号整数换算过来就是几十亿了,不可能会有一家公司的业务量在一天内达到这么高的)
测试代码如下所示:
@SpringBootTest
class RedisWorkerTest {
@Autowired
private RedisWorker redisWorker;
private ExecutorService pool = Executors.*newFixedThreadPool*(500);
@Test
void nextId() throws InterruptedException {
// 启动300个线程,每个线程取100次ID
CountDownLatch countDownLatch = new CountDownLatch(300);
Runnable task = () -> {
for(int i = 0; i < 100; ++i) {
**long id = redisWorker.nextId("order");**
System.*out*.println("id = " + id);
}
countDownLatch.countDown();
};
long begin = System.*currentTimeMillis*();
for(int i = 0; i < 300; ++i) {
pool.submit(task);
}
countDownLatch.await();
long end = System.*currentTimeMillis*();
System.*out*.println("用时:" + (end - begin) + "ms");
}
}二、秒杀下单功能中的超卖问题💥


只要 version 字段一旦变化,在扣减库存的时候就能判断版本号是否发生变化,如果和扣减之前获取的 version 不一致,那么说明有人改动过了库存,说明当前线程读到的是脏数据,所以不能进行库存的扣减,防止出现并发问题!
所以本质就是控制一个每次扣减时候都会单调变化的数值,而恰好库存 stock 变量就是会一直扣减的,所以完全没必要多用一个 version 来实现,只需要用 stock来判断即可 !

此外,这里不需要单独加锁的原因是 mysql 具有行级锁 、原子性 。mysql 会把 stock > 0 条件和 stock = stock - 1 的操作放在一个原子操作里执行:
-
如果多个线程同时执行,只会有一个线程成功更新 。
-
失败的线程返回
0 rows affected,Java 端update()就是false。
@Override
@Transactional
public Result seckill(Long voucherId) {
*// 1. 根据id,获取优惠券信息*
* *SeckillVoucher seckillVoucher = seckillVoucherService.query().eq("voucher_id", voucherId).one();
*// 2. 判断秒杀是否开始*
* *if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.*now*())) {
return Result.*fail*("活动尚未开始!");
}
*// 3. 判断秒杀是否结束*
* *if(seckillVoucher.getEndTime().isBefore(LocalDateTime.*now*())) {
return Result.*fail*("活动已经结束!");
}
*// 4. 判断是否有库存*
* *if(seckillVoucher.getStock() < 1) {
return Result.*fail*("优惠券已被抢完!");
}
*// 5. 如果有库存,则库存减少一个*
* *boolean isDeduct = seckillVoucherService.update()
.eq("voucher_id", voucherId)
**.gt("stock", 0)**
* ***.setSql("stock = stock - 1")** ** ** ***// 利用CAS机制,防止出现并发问题💥** *
.update();
if(!isDeduct) {
return Result.*fail*("库存不足!");
}
*// 6. 创建订单,并且保存到数据库*
* *VoucherOrder order = new VoucherOrder();
order.setUserId(UserHolder.*getUser*().getId());
order.setVoucherId(voucherId);
order.setId(redisWorker.nextId("order")); *// 使用redis创建的全局唯一ID,作为订单ID*
* *save(order);
*// 7. 返回订单ID*
* *return Result.*ok*(order.getId());
}三、解决一人一单问题💥
一人一单的解决思路不难,只需要在扣减库存之前判断一下该用户是否已经在订单表中存在对应的订单即可,而处理细节是比较难的,因为不像前面超卖问题可以用乐观锁解决,这里需要用悲观锁比如 synchronized 来互斥同一个用户,所以会存在以下问题:
1. spring 事务通过代理实现以及存在的问题
Spring 事务的核心是 AOP 动态代理 。
① 基本原理
-
你写了一个类:
@Service public class OrderService { @Transactional public void createOrder() { // 下单逻辑 } } -
Spring 容器启动时,会给
OrderService生成一个 代理对象 (JDK 动态代理或 CGLIB)。 -
代理对象里会在调用
createOrder()方法时 :-
开启事务 (从事务管理器拿 Connection,设置自动提交=false)。
-
执行原始方法逻辑。
-
如果异常抛出 → 回滚事务。
-
如果正常完成 → 提交事务。
-
② 为什么自调用事务失效
@Service
public class OrderService {
public void outer() {
this.inner(); // 调用事务方法
}
@Transactional
public void inner() {
// 期望有事务
}
}-
调用
outer()时,调用的是代理对象。 -
但
outer()内部的this.inner()调用,走的是当前类的this引用 ,没有经过代理 。 -
因为没经过代理,Spring 就不会在
inner()执行前后织入事务逻辑。 -
结果:事务失效。
③ 解决方案
-
让调用走代理对象:
@Autowired private OrderService self; // 实际注入的是代理对象 public void outer() { self.inner(); // 走代理,事务生效 } -
使用
AopContext.currentProxy()获取当前服务类的代理对象((OrderService) **AopContext.currentProxy()** ).inner(); // 注意需要事先在pom.xml中导入下面的包 *<!-- aop包,Spring Boot 会自动帮你拉取 aspectjweaver 和 aspectjrt -->* <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> // 并且在spring主程序中打开exposeProxy: @MapperScan("com.hmdp.mapper") @SpringBootApplication **@EnableAspectJAutoProxy(exposeProxy = true)** public class HmDianPingApplication { public static void main(String[] args) { SpringApplication.*run*(HmDianPingApplication.class, args); } } -
或者直接把
inner()方法拆到另一个类。
2. 代码中多处细节
-
需要在有
@Transactional修饰的方法外面加锁,而不是在该方法内部加锁 。因为等到方法结束才会提交事务,此时如果在方法内部synchronized(this)或锁对象时,事务可能还没提交,线程锁就释放了。正确做法如下所示:-
单机锁情况 (比如防止一个用户重复下单):在调用
@Transactional方法之前加synchronized,整个事务过程都包裹在锁内。synchronized (userId.toString().intern()) { orderService.createOrder(voucherId, userId); // 带 @Transactional }这样能保证:线程锁和事务边界保持一致。
-
分布式锁情况 (多节点部署): synchronized 就不行了,要用 Redis 分布式锁、Zookeeper 等,把并发冲突控制在 事务方法调用之前 。
-
-
因为加锁是为了同一个用户只能下单一次,所以是互斥同一个用户,那么对于不同用户来说就没办法互斥了,所以可以用
userId作为锁对象,提高效率 ! -
由于
toString()每次都会创建一个新对象,所以锁就不一样,没办法互斥同一个用户,正确做法是使用intern()方法将字符串放到字符串常量池 ,这样子保证每个用户id每次拿到的字符串对象都是同一个!
@Override
public Result seckill(Long voucherId) {
*// 1. 根据id,获取优惠券信息*
* *SeckillVoucher seckillVoucher = seckillVoucherService.query().eq("voucher_id", voucherId).one();
*// 2. 判断秒杀是否开始*
* *if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.*now*())) {
return Result.*fail*("活动尚未开始!");
}
*// 3. 判断秒杀是否结束*
* *if(seckillVoucher.getEndTime().isBefore(LocalDateTime.*now*())) {
return Result.*fail*("活动已经结束!");
}
*// 4. 判断是否有库存*
* *if(seckillVoucher.getStock() < 1) {
return Result.*fail*("优惠券已被抢完!");
}
*// 💥① ****需要在deductStock方法外面加锁,因为该方法有@Transactional修饰,等到方法结束才会提交事务,所以如果在方法内加锁,等到解锁后事务才会提交,同样会有并发安全问题** **!*
* // 💥② ****因为加锁是为了同一个用户只能下单一次,所以是互斥同一个用户,那么对于不同用户来说就没办法互斥了,所以可以用userId作为锁对象,提高效率** **!*
* // 💥③ ****由于toString()每次都会创建一个新对象,所以锁就不一样,没办法互斥同一个用户,正确做法是使用 intern() 方法将字符串放到字符串常量池,这样子保证每个用户id每次拿到的字符串对象都是同一个** *
* *Long userId = UserHolder.*getUser*().getId();
synchronized(**userId.toString().intern()** ) {
IVoucherOrderService orderService = (IVoucherOrderService) **AopContext.** ***currentProxy** *();
return orderService.deductStock(voucherId, userId);
}
}
**@Transactional**
public Result deductStock(Long voucherId, Long userId) {
*// 5. ****解决一人一单问题** **💥*
* // 5.1 根据用户id,查询对应订单*
* *Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
*// 5.2 如果存在对应订单,则直接返回*
* *if(count > 0) {
return Result.*fail*("已经购买过优惠券了,不可重复购买!");
}
*// 6. 如果有库存,则库存减少一个*
* *boolean isDeduct = seckillVoucherService.update()
.eq("voucher_id", voucherId)
.gt("stock", 0) *// 利用CAS机制,防止并发问题💥*
* *.setSql("stock = stock - 1")
.update();
if(!isDeduct) {
return Result.*fail*("库存不足!");
}
*// 7. 创建订单,并且保存到数据库*
* *VoucherOrder order = new VoucherOrder();
order.setUserId(userId);
order.setVoucherId(voucherId);
order.setId(redisWorker.nextId("order")); *// 使用redis创建的全局唯一ID,作为订单ID*
* *save(order);
*// 8. 返回订单ID*
* *return Result.*ok*(order.getId());
}分布式锁
虽然上面通过加锁实现了单台服务器中一人一单的问题,但是在多台服务器一起执行的时候,还是会出现问题,因为不同服务器也就是不同 tomcat 实例中的锁监视器都不相同,就算用户 ID 一致,锁监视器也不是同一个,此时就需要引入分布式锁来解决该问题了!
一、基本原理 && 实现方式
-
分布式锁:满足分布式系统或集群模式下多线程可见,并且可以互斥的锁。
-
分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路。

常见的分布式锁有三种:

实现思路:这里利用 redis 的 SETNX 方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个 key 了,返回了 1,如果结果是 1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试。
二、解决分布式锁误删问题💥
在 setnx 的时候,如果设置的 key 是 前缀_业务名_线程ID,value 是 线程ID,并且设置了过期时间,但是如果出现线程1获取锁之后,由于业务阻塞(比较费时)导致业务还没完成就到了锁的过期时间了,此时锁会超时释放。
此时假设线程2是其他JVM中 线程ID 和线程1是一样的线程,那么当线程1业务完成时候,会去释放锁,因为此时的 value 是 线程ID,并不能区分是不是不同 JVM 中的同一线程,所以线程1就很有可能误删其他 JVM 中本不该删除的锁 ,最后导致线程2的锁就被错误释放,就会导致后面一连串更错误的结果,如下图所示:

解决方法很简单,就是修改一下 value 的标识,改用 UUID + 线程ID 来标识它!
-
UUID:用于区分不同 JVM 中的同一线程 -
线程ID:用于区分同一 JVM 中的不同线程
并且在删除键的时候,不能直接删除锁 ,而是要先拿到 redis 中该锁对应的 value,然后与当前线程的标识进行对比,如果相同,说明该锁是当前线程的,才能进行删除;否则什么都不做!
所以锁的实现如下所示:
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String transaction_name; *// 业务名称,用于拼接key*
* *private String uuid = UUID.*randomUUID*().toString(); *// uuid用于区分不同jvm中的同一线程*
* *private final static String *KEY_PREFIX *= "lock:"; *// key前缀*
* *public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String transaction_name) {
this.stringRedisTemplate = stringRedisTemplate;
this.transaction_name = transaction_name;
}
@Override
public boolean tryLock(Long expireTime) {
*// 1. 获取 “uuid + 线程id” 标识作为value*
* // 💥uuid用于区分不同jvm中的同一线程*
* // 💥线程id用于区分同一jvm中的线程*
* ***String threadId = uuid + "-" + Thread.** ***currentThread** ***().getId()** ;
*// 2. 存放到redis中*
* *Boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(*KEY_PREFIX *+ transaction_name, threadId, expireTime, TimeUnit.*SECONDS*);
*// 3. 返回是否存放成功*
* *return BooleanUtil.*isTrue*(isLock);
}
@Override
public void unlock() {
*// 1. 获取当前线程的 “uuid + 线程id” 标识*
* *String threadId = uuid + "-" + Thread.*currentThread*().getId();
*// 2. 获取redis中该锁的标识*
* *String s = stringRedisTemplate.opsForValue().get(*KEY_PREFIX *+ transaction_name);
*// 3. 判断当前标识是否与redis中的标识相同*
* *if(threadId.equals(s)) {
*// 4. 如果相同,则删除该锁*
* *stringRedisTemplate.delete(*KEY_PREFIX *+ transaction_name);
}
}
}有了锁的实现之后,只需要将原来的 synchronized 修改成我们自己实现的锁即可:
@Override
public Result seckill(Long voucherId) {
*// 1. 根据id,获取优惠券信息*
* *SeckillVoucher seckillVoucher = seckillVoucherService.query().eq("voucher_id", voucherId).one();
*// 2. 判断秒杀是否开始*
* *if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.*now*())) {
return Result.*fail*("活动尚未开始!");
}
*// 3. 判断秒杀是否结束*
* *if(seckillVoucher.getEndTime().isBefore(LocalDateTime.*now*())) {
return Result.*fail*("活动已经结束!");
}
*// 4. 判断是否有库存*
* *if(seckillVoucher.getStock() < 1) {
return Result.*fail*("优惠券已被抢完!");
}
*// 💥① 需要在方法外面加锁,因为该方法有@Transactional修饰,等到方法结束才会提交事务,所以如果在方法内加锁,等到解锁后事务才会提交,同样会有并发安全问题!*
* // 💥② 因为加锁是为了同一个用户只能下单一次,所以是互斥同一个用户,那么对于不同用户来说就没办法互斥了,所以可以用userId作为锁对象,提高效率!*
* // 💥③ 由于toString()每次都会创建一个新对象,所以锁就不一样,没办法互斥同一个用户,正确做法是使用 intern() 方法将字符串放到字符串常量池,这样子保证每个用户id每次拿到的字符串对象都是同一个*
* *Long userId = UserHolder.*getUser*().getId();
*// synchronized(userId.toString().intern()) {*
*// IVoucherOrderService orderService = (IVoucherOrderService) AopContext.currentProxy();*
*// return orderService.deductStock(voucherId, userId);*
*// }*
* ****// 引入分布式锁💥** *
* // 1. 首先获取分布式锁对象*
* *SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
*// 2. 判断是否获取锁*
* *boolean isLock = lock.tryLock(120L);
if(!isLock) {
*// 3. 如果没拿到锁,说明同一用户已经拿到锁并且大概率要抢购订单了,所以当前线程不能再获取了*
* *return Result.*fail*("您已经抢过优惠券了,请勿重复抢购!");
}
*// 4. 如果拿到锁了,则开始抢购业务*
* *try {
IVoucherOrderService orderService = (IVoucherOrderService) AopContext.*currentProxy*();
return orderService.deductStock(voucherId, userId);
} finally {
lock.unlock(); *// 别忘了释放锁!!!*
* *}
}三、解决分布式锁原子性问题💥
1. 问题引入
上面虽然解决了代码逻辑上分布式锁误删问题,但是还存在一些极端场景,如下所示:
-
线程1获取到锁,执行业务 -
线程1执行完业务之后,获取到了锁标识,并且判断一致,准备释放锁(也就是删除锁) -
但此时由于一些情况比如说 JVM 垃圾回收机制导致
线程1阻塞了一会,延误了释放锁的时机,而刚好该锁也超时自动释放了 -
线程2此时就获取到了锁(假设线程2和线程1是同一用户,所以它们获取锁和删除锁的 key 是相同的),开始执行业务 -
此时
线程1阻塞完成了,开始释放锁,由于它前面已经获取过锁标识了(此时这个锁标识中的 value 实际上是旧数据),所以它开始删除锁 -
在删除锁的时候由于
线程1和线程2的锁标识是相同的,所以最后线程1删除的时候就直接把线程2的锁给误删了,结果也就错了

出现该问题的关键,就是获取锁标识、判断锁标识是否一致、删除锁这几步不是原子性操作,所以关键是把它们封装成原子操作。
问题:
在刚学习这部分内容的时候遇到一个问题,就是要防止
线程1和线程2判断锁标识是相同的,那么为什么不在 key 中拼上一个 uuid 保持唯一性,这样子不就不会误删了吗?这样子确实不会误删了,但是在定义锁标识的时候用
用户id来标识锁,就是为了让同一用户访问的时候才上锁,如果用唯一的 uuid 拼到 key 中,这样子就对所有用户都上锁了,那么就没有锁竞争的效果了,因为每个线程 key 都不一样了,相当于没锁 !所以拼 uuid 到 key 会破坏锁的互斥语义(每个线程拿不同的 key,根本不冲突),没法解决问题。正确做法是:
key 用业务标识(如
lock:order:123)。value 写
uuid+threadId来区分持有者。unlock 用 Lua 脚本保证“比对 value + 删除 key”一步完成,避免误删。
2. 用 Lua 脚本解决原子性问题
https://www.runoob.com/lua/lua-tutorial.html
-
Redis 为什么要用 Lua
-
Redis 单线程,Lua 脚本会串行执行 ,所以能避免并发竞争。
-
把多条命令写进脚本,一次执行,避免网络往返(RTT)。
-
常用于:扣减库存、分布式锁、限流器 。
-
-
使用方式 Redis 命令加载脚本
-
EVAL命令EVAL script numkeys key [key ...] arg [arg ...]-
script:Lua 脚本 -
numkeys:key 的个数 -
key [key ...]:传入的 Redis key -
arg [arg ...]:额外参数
-
-
所以首先编写一下 Lua 脚本文件,完成之前 unlock() 中的任务:
*-- 1. 获取锁标识*
*-- 2. 判断锁标识是否一致*
*-- 3. 一致的话对锁删除*
if(*redis*.*call*('get', *KEYS*[1]) == *ARGV*[1]) then
return *redis*.*call*('del', *KEYS*[1])
end
return 0然后在 unlock() 中通过 stringRedisTemplate.``execute``() 执行该脚本文件(要先用 DefaultRedisScript 类导入脚本文件)
private final static DefaultRedisScript<Long> *unlockRedisScript*;
static {
*unlockRedisScript *= new DefaultRedisScript<>();
*unlockRedisScript*.setLocation(new ClassPathResource("unlock.lua"));
*unlockRedisScript*.setResultType(Long.class);
}
@Override
public void unlock() {
* *stringRedisTemplate.**execute** ( // execute函数就是eval命令
*unlockRedisScript*,
Collections.*singletonList*(*KEY_PREFIX *+ transaction_name),
uuid + "-" + Thread.*currentThread*().getId());
}分布式锁 -- Redisson

一、什么是Redisson
Redisson 是一个基于 Redis 的分布式工具包,核心是分布式数据结构和并发控制 。它不是简单的 StringRedisTemplate 封装,而是提供了很多类似 Java 原生 java.util.concurrent 的 API,只不过底层存储和协调都交给 Redis。
主要功能如下所示:
-
分布式锁
-
RLock可重入锁(类似ReentrantLock)。 -
FairLock公平锁(按请求顺序获取)。 -
ReadWriteLock读写锁。 -
MultiLock、RedLock跨节点锁。 -
自带 "看门狗" 机制:锁快过期时会自动延长,避免业务长耗时导致锁过期。
-
-
分布式对象
-
RMap、RList、RSet、RQueue等,API 与 Java 集合几乎一致。 -
底层自动映射到 Redis 数据结构。
-
-
分布式工具类
-
RSemaphore分布式信号量 -
RCountDownLatch分布式闭锁 -
RBloomFilter布隆过滤器 -
RRateLimiter限流器
-
-
任务调度
- 分布式执行服务、延时队列、定时任务
二、快速入门
-
导入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.52.0</version> </dependency> <!-- 如果运行显示缺少pom依赖,那么要添加下面这个netty依赖 --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-transport-native-unix-common</artifactId> <version>4.1.112.Final</version> </dependency> -
配置 Redisson 客户端,在 config 包下新建
RedissonConfig配置类@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setPassword("123123"); return Redisson.*create*(config); } } -
使用分布式锁
@Resource* *private RedissonClient redissonClient;* *@Override* *public Result seckill(Long voucherId) {* * // 1. 根据id,获取优惠券信息* * SeckillVoucher seckillVoucher = seckillVoucherService.query().eq("voucher_id", voucherId).one();* * // 2. 判断秒杀是否开始* * if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {* * return Result.fail("活动尚未开始!");* * }* * // 3. 判断秒杀是否结束* * if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {* * return Result.fail("活动已经结束!");* * }* * // 4. 判断是否有库存* * if(seckillVoucher.getStock() < 1) {* * return Result.fail("优惠券已被抢完!");* * }* * Long userId = UserHolder.*getUser*().getId(); * * **// 引入redisson分布式锁💥💥** * * // 1. 首先获取分布式锁对象* * RLock lock = redissonClient.**getLock** ("order:" + userId);* * // 2. 尝试上锁* * boolean isLock = lock.**tryLock** ();* * // 3. 如果没拿到锁,说明同一用户已经拿到锁并且大概率要抢购订单了,所以当前线程不能再获取了* * if(!isLock) {* * return Result.fail("您已经抢过优惠券了,请勿重复抢购!");* * }* * // 4. 如果拿到锁了,则开始抢购业务* * try {* * IVoucherOrderService orderService = (IVoucherOrderService) AopContext.currentProxy();* * return orderService.deductStock(voucherId, userId);* * } finally {* * lock.**unlock** ();* * }* *}
三、原理(了解即可)


秒杀优化 -- 异步
https://cyborg2077.github.io/2022/10/22/RedisPractice/#%E7%A7%92%E6%9D%80%E4%BC%98%E5%8C%96
达人探店
一、点赞 && 排行榜 的数据结构选取

二、获取点赞排行榜中用户信息的注意事项
-
对于数据库要获取多个
User的信息,可以在查询时候使用in方法。 -
虽然在
ZSET中是按照时间戳来排序的,但是从数据库查出User之后,发现是根据ID来排序的,此时排行榜的顺序就错了。- 解决方法: 在查询时候使用
last方法(表示最后插入一条 sql 语句),插入一条排序语句,专门指定field``("id", 希望的序列),然后通过StrUtil.join()将前面查出来的序列进行分割,传入前面的方法,达到动态传参的效果,具体参考下面代码。
- 解决方法: 在查询时候使用
// 获取点赞排行榜中的用户信息UserDTO
@Override
public Result likesBlog(Long id) {
*// 拿到前五个用户id(存储的是string类型)*
* *String key = RedisConstants.*BLOG_LIKED_KEY *+ id;
Set<String> showList_string = stringRedisTemplate.opsForZSet().**range** (key, 0, 4);
if(CollectionUtil.*isEmpty*(showList_string)) {
return Result.*ok*(Collections.*emptyList*()); *// 不存在用户点赞,则直接返回空列表*
* *}
*// 转化为Long类型*
* *List<Long> showList_id = showList_string.stream()
.map(Long::*valueOf*)
.collect(Collectors.*toList*());
*// 然后查出User,转化为UserDTO类型,进行返回*
* *String idsStr = **StrUtil.** ***join** *(",", showList_id);
List<UserDTO> userDTOS = userService.query().**in** ("id", showList_id)
.**last** ("order by field(id, " + idsStr + ")").list()
.stream()
.map(user -> BeanUtil.*copyProperties*(user, UserDTO.class))
.collect(Collectors.*toList*());
return Result.*ok*(userDTOS);
}好友关注
-
了解 Feed 流的两种实现方式
-
Timeline(三种具体实现方式)
-
拉模式
-
推模式
-
推拉模式
-
-
智能排序
-
-
了解 Feed 流的滚动分页实现:
-
如果排行榜或者信息箱中的数据会不断变化,则不能采用传统的分页模式,必须使用滚动分页 ,也就是用一个变量记录每次取到的最后一条数据,下次取数据从最后一条数据的下一个位置开始取,就不会出现重复读取的情况。
-
并且对于数据会不断变化的情况要实现滚动分页时,不能使用
List,而得用SortedSet,因为SortedSet中可以带上score(一般设置为时间戳),而不像List只能通过下标去获取元素,这样子不仅可以进行排序,并且将score作为游标,就能继续往后取元素,避免了数据重复和丢失。
-
实现过程:实际上就是发布博客后,将博客内容推送到粉丝中的 redis 信息箱(利用 zset 存储),然后当前端在关注消息页面中刷新时候会根据 offset 以及 lastId 访问后端,拿到数据,后端根据这两个参数拿到对应 redis 中分页的数据,转化后进行返回,这依靠的就是 zset 的排序以及范围查询功能!
// 发布博客后推送消息给粉丝
@Override
public Result saveBlog(Blog blog) {
*// 1. 获取登录用户*
* *UserDTO user = UserHolder.*getUser*();
blog.setUserId(user.getId());
*// 2. 保存探店博文*
* *save(blog);
***// 3. 获取该博主的所有粉丝** *
* *List<Long> followUserIds = followService.query().eq("follow_user_id", user.getId()).list()
.stream()
.map(Follow::getUserId)
.collect(Collectors.*toList*());
***// 4. 将博客推送给粉丝** *
* *for(Long id : followUserIds) {
stringRedisTemplate.opsForZSet().add(RedisConstants.*FEED_KEY *+ id, blog.getId().toString(), System.*currentTimeMillis*());
}
*// 返回id*
* *return Result.*ok*(blog.getId());
}
// 粉丝通过关注页面拿到推送的数据
@Override
public Result queryFollow(Long lastId, Integer offset) {
*// 1. 获取当前用户信息*
* *Long userId = UserHolder.*getUser*().getId();
*// 2. 获取redis中的推送内容*
* *Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.**reverseRangeByScoreWithScores** (
RedisConstants.*FEED_KEY *+ userId,
0,
lastId,
offset,
2
);
if(typedTuples == null || typedTuples.isEmpty()) {
return Result.*ok*(Collections.*emptyList*());
}
***// 3. 解析要传给前端的部分** *
* *List<Long> list = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for(ZSetOperations.TypedTuple<String> e : typedTuples) {
*// 取出博客id、时间戳*
* *long blogId = Long.*parseLong*(e.getValue());
long score = e.getScore().longValue();
*// 给三个变量赋值*
* *list.add(blogId);
if(minTime == score) {
os++;
} else {
minTime = score;
os = 1;
}
}
*log*.info("list=" + list);
*// 4. 将List<Long>转化为List<Blog>*
* *String idsStr = StrUtil.*join*(",", list);
List<Blog> blogs = query().in("id", list).last("order by field(id, " + idsStr + ")").list();
for(Blog blog : blogs) {
*// 获取用户信息,填充Blog*
* *setUser(blog);
*// 追加判断blog是否被当前用户点赞,逻辑封装到isBlogLiked方法中*
* *isBlogLiked(blog);
}
*log*.info("blogs=" + blogs);
*// 5. 封装成ScrollResult*
* *ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setOffset(os);
scrollResult.setMinTime(minTime);
*// 6. 返回*
* *return Result.*ok*(scrollResult);
}附近商店
https://cyborg2077.github.io/2022/10/22/RedisPractice/#%E9%99%84%E8%BF%91%E5%95%86%E6%88%B7
- 在导入坐标到 Redis 中时,需要根据
typeid进行分组,方便在 Redis 中区分开。正常操作是写个 for 循环然后插入到 Map 中,但其实可以直接通过stream流提供的接口一步到位,如下所示:
Map<Long, List<Shop>> collect = shops.stream().**collect** (**Collectors.** ***groupingBy** *(Shop::getTypeId));