0. 实战导读
🎯 学习目标
本教程将带你从Redis基础概念到实际项目应用,通过8个实战案例深入理解Redis的强大功能。
短信登录 商户查询缓存 优惠券秒杀 附近商户 UV统计 用户签到 好友关注 探店点赞 📱 短信登录
使用Redis共享Session实现分布式登录系统,解决传统Session跨域问题
🔍 商户查询缓存
深入理解缓存击穿、缓存穿透、缓存雪崩问题,掌握缓存策略和解决方案
🎫 优惠券秒杀
Redis计数器 + Lua脚本实现高性能秒杀,分布式锁防止超卖
📍 附近商户
利用Redis GEOHash实现地理位置查询,支持附近商家搜索
📊 UV统计
使用HyperLogLog进行海量数据去重统计,内存占用极低
✅ 用户签到
BitMap实现用户签到功能,节省存储空间
👥 好友关注
基于Set集合实现关注、取消关注、共同关注等社交功能
👍 探店点赞
List实现点赞列表,SortedSet实现点赞排行榜
1. 短信登录
1.1、项目架构分析
1.1.1 系统架构图
1.1.2 架构设计解析
技术栈
负载均衡:Nginx
应用服务:Tomcat集群
数据存储:MySQL + Redis
缓存策略:多级缓存架构
请求流程分析:
客户端请求 → Nginx负载均衡
Nginx基于七层模型处理HTTP协议
支持Lua脚本直接访问Redis
静态资源服务,轻松扛下上万并发
负载均衡 → Tomcat集群
4核8G Tomcat约处理1000并发
Nginx负载均衡分流,集群支撑高并发
动静分离降低Tomcat压力
数据访问层
MySQL企业级:16-32核CPU,32-64G内存
并发能力:4000-7000 QPS
Redis集群缓存,降低数据库压力
1.1.3 项目导入步骤
🖥️ 后端项目导入
解压后端项目源码
配置数据库连接
导入SQL脚本
启动项目
🌐 前端项目导入
解压前端工程
安装依赖
配置API接口地址
启动前端服务
🚀 项目运行
访问地址:http://localhost:8080
1.2 登录流程设计
1.2.1 登录流程图
1.2.2 详细流程解析
📤 发送验证码
手机号校验
前端提交手机号
后端验证手机号格式
格式错误:返回错误信息
验证码生成与发送
生成6位随机验证码
保存验证码到Session
调用短信服务发送验证码
🔐 用户登录
参数校验
用户处理
根据手机号查询用户
用户不存在:创建新用户
用户存在:更新登录信息
Session管理
✅ 状态校验
请求拦截
从Cookie获取JsessionId
根据SessionId获取用户信息
权限控制
用户不存在:返回401未授权
用户存在:信息存入ThreadLocal
请求放行
1.3 核心代码实现
1.3.1 页面交互流程
1.3.2 发送验证码功能
功能说明 :验证手机号格式,生成6位随机验证码并保存到Session
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Service public class UserServiceImpl implements UserService { @Override public Result sendCode (String phone, HttpSession session) { if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!" ); } String code = RandomUtil.randomNumbers(6 ); session.setAttribute("code" , code); log.debug("发送短信验证码成功,验证码:{}" , code); return Result.ok(); } }
1.3.3 用户登录功能
安全提醒 :验证码校验通过后,需要根据手机号查询或创建用户,并将用户信息保存到Session
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @Service public class UserServiceImpl implements UserService { @Override public Result login (LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!" ); } Object cacheCode = session.getAttribute("code" ); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.toString().equals(code)) { return Result.fail("验证码错误" ); } User user = query().eq("phone" , phone).one(); if (user == null ) { user = createUserWithPhone(phone); } session.setAttribute("user" , user); return Result.ok(); } private User createUserWithPhone (String phone) { User user = new User (); user.setPhone(phone); user.setNickName("user_" + RandomUtil.randomString(6 )); userService.save(user); return user; } }
1.4 登录拦截器实现
1.4.1 Tomcat运行原理
Tomcat线程模型解析
监听线程 :监听端口,接收客户端连接
Socket连接 :每对请求-响应创建独立Socket
线程池 :从线程池获取线程处理请求
请求处理 :线程转发到Controller→Service→DAO→DB
响应返回 :处理完成后数据写回客户端Socket
1.4.2 ThreadLocal线程隔离
ThreadLocal使用要点
每个用户请求对应Tomcat线程池中的一个线程,通过ThreadLocal实现线程间的数据隔离,确保每个线程操作自己的数据副本。
1.4.3 登录拦截器代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("user" ); if (user == null ) { response.setStatus(401 ); return false ; } UserHolder.saveUser((User) user); return true ; } }
1.4.4 拦截器配置
配置说明
order(0):Token刷新拦截器,优先级最高
order(1):登录验证拦截器,优先级次之
excludePathPatterns:配置不需要拦截的路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(new RefreshTokenInterceptor (stringRedisTemplate)) .addPathPatterns("/**" ) .order(0 ); registry.addInterceptor(new LoginInterceptor ()) .excludePathPatterns( "/shop/**" , "/voucher/**" , "/shop-type/**" , "/upload/**" , "/blog/hot" , "/user/code" , "/user/login" ) .order(1 ); } }
1.5 用户信息安全处理
1.5.1 敏感信息隐藏的必要性
安全风险警告
直接返回完整的User实体对象会暴露用户敏感信息(如密码、手机号、邮箱等),存在严重的安全隐患。必须通过DTO对象进行数据脱敏。
1.5.2 解决方案:UserDTO数据传输对象
UserDTO定义:
1 2 3 4 5 6 7 8 9 @Data @NoArgsConstructor @AllArgsConstructor public class UserDTO { private Long id; private String nickName; private String icon; }
1.5.3 代码修改
1. 登录方法修改:
1 2 session.setAttribute("user" , BeanUtil.copyProperties(user, UserDTO.class));
2. 拦截器修改:
1 2 UserHolder.saveUser((UserDTO) user);
3. UserHolder工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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(); } }
1.5.4 安全效果对比
返回方式
包含字段
安全性
推荐程度
User实体
id, phone, password, email…
❌ 危险
禁止使用
UserDTO
id, nickName, icon
✅ 安全
强烈推荐
1.6 Session共享问题
1.6.1 分布式Session问题分析
集群环境下的Session一致性挑战
在分布式系统中,每个Tomcat服务器都有自己的Session存储。当用户请求被分发到不同服务器时,会导致Session数据不一致,用户需要重复登录。
典型问题场景:
1 2 3 用户请求 → Nginx负载均衡 → Tomcat1 (保存Session) ↓ Tomcat2 (无Session数据)
早期解决方案对比:
方案
原理
优点
缺点
Session复制
各服务器间同步Session
实现简单
网络开销大,性能差
Session粘连
固定用户到某台服务器
无需额外开发
负载不均,单点故障
Session集中存储
使用Redis统一管理
性能高,可扩展
需要额外组件
1.6.2 Redis解决方案
推荐方案:Redis集中存储
使用Redis作为Session的集中存储,所有服务器共享同一份Session数据,彻底解决分布式环境下的Session一致性问题。
架构设计:
1 2 3 4 用户请求 → Nginx负载均衡 → Tomcat1 ↓ ↓ Redis ←------ Tomcat2 (集中Session存储)
核心优势:
数据共享 :所有服务器访问同一份Session数据
高性能 :Redis内存存储,读写速度快
可扩展 :支持集群部署,水平扩展
持久化 :支持数据持久化,防止数据丢失
1.7 Redis代替Session的业务流程
1.7.1 Redis数据结构选择
数据结构对比分析
在Redis中存储用户登录信息时,需要根据数据特性和使用场景选择合适的数据结构。
String vs Hash结构对比:
对比维度
String结构
Hash结构
内存占用
较高(序列化开销)
较低(字段独立存储)
读写性能
简单快速
支持字段级操作
数据更新
需要整体更新
支持字段级更新
适用场景
简单键值对
复杂对象存储
选择建议:
如果用户信息简单,使用String结构
如果用户信息复杂,需要频繁更新部分字段,使用Hash结构
1.7.2 Key设计策略
Key设计原则
Redis的Key需要满足唯一性和可携带性,同时避免暴露用户敏感信息。
Key设计要点:
唯一性 :确保每个用户的Key都是唯一的
可携带性 :Key需要方便在前后端之间传递
安全性 :避免使用手机号等敏感信息作为Key
推荐方案:
1 2 Key格式:login:token:{随机token} 示例:login:token:a3f2b8c9d1e4f5g6
Token生成策略:
1 2 3 String token = UUID.randomUUID().toString(true );String key = LOGIN_USER_KEY + token;
1.7.3 整体访问流程
📱 步骤1:用户登录验证
用户提交手机号和验证码,系统验证信息一致性
🔍 步骤2:用户信息查询
根据手机号查询用户信息,不存在则创建新用户
💾 步骤3:Redis数据存储
将用户信息保存到Redis,设置合理的过期时间
🔑 步骤4:Token返回
生成随机token作为登录凭证,返回给前端
🔐 步骤5:登录状态校验
后续请求携带token,系统验证Redis中是否存在对应数据
1.8 基于Redis实现短信登录
1.8.1 实现思路回顾
Redis登录方案核心
将传统的Session存储替换为Redis存储,通过Token机制实现无状态的分布式登录认证。
核心改进点:
存储位置 :从服务器内存 → Redis缓存
认证方式 :从SessionID → 随机Token
数据格式 :从完整对象 → 精简DTO
有效期管理 :支持灵活的过期时间设置
1.8.2 核心代码实现
UserServiceImpl登录方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 @Service @Slf4j public class UserServiceImpl extends ServiceImpl <UserMapper, User> implements IUserService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result login (LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!" ); } String cacheCode = stringRedisTemplate.opsForValue() .get(LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)) { return Result.fail("验证码错误" ); } User user = query().eq("phone" , phone).one(); if (user == null ) { user = createUserWithPhone(phone); } String token = UUID.randomUUID().toString(true ); 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())); String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); return Result.ok(token); } }
Redis常量配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class RedisConstants { public static final String LOGIN_CODE_KEY = "login:code:" ; public static final Long LOGIN_CODE_TTL = 2L ; public static final String LOGIN_USER_KEY = "login:token:" ; public static final Long LOGIN_USER_TTL = 30L ; public static final String USER_SIGN_KEY = "sign:" ; public static final String CACHE_SHOP_KEY = "cache:shop:" ; public static final Long CACHE_SHOP_TTL = 30L ; public static final String LOCK_SHOP_KEY = "lock:shop:" ; public static final Long LOCK_SHOP_TTL = 10L ; }
前端登录调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 async sendCode ( ) { const result = await request.post ('/user/code' , { phone : this .phone }); if (result.code === 200 ) { this .$message .success ('验证码发送成功' ); this .startCountdown (); } }, async login ( ) { const result = await request.post ('/user/login' , { phone : this .phone , code : this .code }); if (result.code === 200 ) { localStorage .setItem ('token' , result.data ); request.defaults .headers .common ['authorization' ] = result.data ; this .$message .success ('登录成功' ); this .$router .push ('/' ); } }
1.9 登录状态刷新优化
1.9.1 初始方案问题分析
初始方案缺陷
原方案中拦截器只拦截需要登录验证的路径,导致用户访问无需拦截的路径时,Token无法得到刷新,可能造成Token过期失效。
问题场景:
1 2 用户访问首页 → 无需拦截 → Token不刷新 → Token过期 用户访问个人中心 → 需要拦截 → Token刷新 → 正常访问
影响分析:
用户活跃状态下仍可能因Token过期被强制登出
用户体验差,需要频繁重新登录
无法准确反映用户的真实活跃状态
1.9.2 双拦截器优化方案
优化思路
采用双拦截器模式:
刷新拦截器 :拦截所有路径,负责Token刷新和用户信息加载
登录拦截器 :只拦截需要登录的路径,负责登录状态校验
架构设计:
1 2 3 4 5 6 7 请求进入 ↓ 刷新拦截器(所有路径)→ 刷新Token有效期 → 加载用户信息到ThreadLocal ↓ 登录拦截器(需登录路径)→ 校验ThreadLocal中用户信息 → 决定是否放行 ↓ 业务处理
优势分析:
所有请求都能刷新Token,避免意外过期
职责分离,代码更清晰
性能影响最小化
1.9.3 代码实现
RefreshTokenInterceptor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 @Component public class RefreshTokenInterceptor implements HandlerInterceptor { private final StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor (StringRedisTemplate stringRedisTemplate) { this .stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("authorization" ); if (StrUtil.isBlank(token)) { return true ; } String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash() .entries(key); if (userMap.isEmpty()) { return true ; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO (), false ); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES); return true ; } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
LoginInterceptor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() == null ) { response.setStatus(401 ); return false ; } return true ; } }
MvcConfig配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private RefreshTokenInterceptor refreshTokenInterceptor; @Resource private LoginInterceptor loginInterceptor; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(refreshTokenInterceptor) .addPathPatterns("/**" ) .order(0 ); registry.addInterceptor(loginInterceptor) .excludePathPatterns( "/user/code" , "/user/login" , "/blog/hot" , "/shop/**" , "/shop-type/**" , "/upload/**" , "/voucher/**" ) .order(1 ); } }
2. 商户查询缓存
2.1 缓存基础概念
2.1.1 什么是缓存?
缓存的本质
缓存就像避震器一样,为系统提供缓冲保护,防止高频访问对数据库造成冲击。
生活类比:
就像越野车的避震器,在崎岖地形中为车体提供保护:
保护作用 :防止硬着陆对车体造成损害
缓冲作用 :吸收冲击力,提供平稳体验
延长寿命 :减少系统组件的磨损
同样,在实际开发中,系统也需要"避震器",防止过高的数据访问量冲击系统,导致操作线程无法及时处理信息而瘫痪。
2.1.2 缓存的技术定义
缓存(Cache) ,就是数据交换的缓冲区 ,俗称的缓存就是缓冲区内的数据 ,一般从数据库中获取,存储于本地代码中。
本地缓存实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 private static final ConcurrentHashMap<String, Object> LOCAL_CACHE = new ConcurrentHashMap <>(); private static final Map<String, Object> SIMPLE_CACHE = new HashMap <>();private static final Cache<String, Object> GUAVA_CACHE = CacheBuilder.newBuilder() .maximumSize(1000 ) .expireAfterWrite(10 , TimeUnit.MINUTES) .build();
Redis缓存实现:
1 2 3 4 5 6 7 8 9 10 11 @Autowired private RedisTemplate<String, Object> redisTemplate;public void setCache (String key, Object value, long timeout) { redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MINUTES); } public Object getCache (String key) { return redisTemplate.opsForValue().get(key); }
缓存特性:
Static修饰 :随着类加载而加载到内存中
Final修饰 :引用关系固定,不用担心赋值导致缓存失效
内存存储 :读写性能远高于磁盘存储
2.1.3 为什么要使用缓存?
缓存的核心价值
缓存数据存储于内存中,而内存的读写性能远高于磁盘,可以大大降低高并发访问带来的服务器读写压力。
性能对比:
存储介质
读取速度
并发能力
成本
内存缓存
纳秒级
10万+QPS
较高
SSD磁盘
微秒级
1万QPS
中等
机械磁盘
毫秒级
1千QPS
较低
业务价值:
用户体验提升 :页面响应从秒级→毫秒级
系统稳定性 :防止高并发冲垮数据库
成本优化 :减少数据库服务器压力
扩展能力 :支持更高的并发访问量
数据规模挑战:
实际开发中,企业数据量从几十万到几千万不等,如果没有缓存作为"避震器",系统几乎无法承受高并发访问。
缓存的代价
缓存技术虽然强大,但也会增加代码复杂度和运维成本,需要权衡使用。
2.1.4 如何使用缓存?
多级缓存架构
在实际开发中,会构建多级缓存体系来最大化系统性能,每一级缓存都有其特定的作用和使用场景。
多级缓存层次:
1 2 3 4 5 6 7 8 9 10 11 用户请求 ↓ 浏览器缓存 (客户端缓存) ↓ CDN缓存 (边缘节点缓存) ↓ 应用层缓存 (Tomcat本地缓存 + Redis分布式缓存) ↓ 数据库缓存 (Buffer Pool) ↓ CPU缓存 (L1/L2/L3)
浏览器缓存:
存储位置 :用户浏览器本地
控制方式 :HTTP头信息控制
典型应用 :静态资源缓存(CSS、JS、图片)
有效期 :通过Cache-Control、Expires设置
1 2 Cache-Control : max-age=3600Expires : Wed, 21 Oct 2025 07:28:00 GMT
应用层缓存:
本地缓存 :Tomcat JVM内存中的Map结构
分布式缓存 :Redis集群存储
使用场景 :热点数据、会话信息、配置数据
1 2 3 4 5 6 private static final Map<String, Object> localCache = new ConcurrentHashMap <>();@Autowired private RedisTemplate<String, Object> redisTemplate;
数据库缓存:
Buffer Pool :InnoDB存储引擎的缓冲池
查询缓存 :MySQL查询结果缓存(8.0已废弃)
作用 :减少磁盘IO,提升查询性能
1 2 SHOW ENGINE INNODB STATUS\G
CPU缓存:
L1缓存 :指令缓存和数据缓存,32-64KB
L2缓存 :256-512KB,速度次于L1
L3缓存 :多核心共享,8-32MB
缓存层级对比:
缓存级别
容量
访问延迟
命中率
L1
32-64KB
1-2ns
80%+
L2
256-512KB
3-5ns
90%+
L3
8-32MB
10-20ns
95%+
2.2 商户缓存实现
2.2.1 业务场景分析
性能瓶颈识别
在查询商户信息时,如果直接操作数据库,当并发量增大时,数据库压力会急剧上升,查询性能会显著下降。
原始代码:
1 2 3 4 5 @GetMapping("/{id}") public Result queryShopById (@PathVariable("id") Long id) { return shopService.queryById(id); }
性能问题:
每次查询都要访问数据库
数据库连接资源有限
高并发下数据库成为瓶颈
相同数据重复查询浪费资源
2.2.2 缓存架构设计
标准缓存模式
采用Cache-Aside模式:查询前先查缓存,缓存命中直接返回,未命中查询数据库并写入缓存。
缓存流程图:
1 2 3 4 5 6 7 8 9 10 11 用户请求 ↓ 查询Redis缓存 ↓ 缓存命中?→ 是 → 直接返回缓存数据 ↓ 否 查询MySQL数据库 ↓ 写入Redis缓存 ↓ 返回查询结果
2.2.3 代码实现
实现思路
代码逻辑:如果缓存命中则直接返回,如果缓存未命中则查询数据库,然后将结果写入Redis缓存。
Service层实现 Controller层 Redis常量 测试验证 ShopServiceImpl:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Service @Slf4j public class ShopServiceImpl extends ServiceImpl <ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryShopById (Long id) { String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } Shop shop = getById(id); if (shop == null ) { return Result.fail("店铺不存在!" ); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); } }
ShopController:
1 2 3 4 5 6 7 8 9 10 @RestController @RequestMapping("/shop") public class ShopController { @GetMapping("/{id}") public Result queryShop (@PathVariable("id") Long id) { return shopService.queryShopById(id); } }
缓存常量配置:
1 2 3 4 public class RedisConstants { public static final String CACHE_SHOP_KEY = "cache:shop:" ; public static final Long CACHE_SHOP_TTL = 30L ; }
性能测试对比:
查询方式
平均响应时间
QPS
数据库压力
直接查数据库
150ms
500
高
Redis缓存
5ms
10,000+
低
性能提升
30倍
20倍
大幅降低
2.3 缓存更新策略
2.3.1 缓存更新策略概述
缓存更新的必要性
内存资源宝贵,当缓存数据过多时需要合理的更新策略来保证系统性能和数据一致性。
三大缓存更新策略:
内存淘汰机制
当Redis内存达到max-memory设置的上限时,自动触发内存淘汰机制,根据配置的淘汰策略删除部分数据。
常见淘汰策略:
策略
描述
适用场景
noeviction
不淘汰,返回错误
数据不能丢失
allkeys-lru
所有key中淘汰最近最少使用
缓存应用
volatile-lru
设置了过期时间的key中淘汰最近最少使用
混合数据
allkeys-random
随机淘汰所有key
测试环境
volatile-ttl
淘汰即将过期的key
临时数据
配置示例:
1 2 3 maxmemory 2gb maxmemory-policy allkeys-lru
TTL过期机制
为缓存数据设置合理的过期时间(TTL),Redis会自动删除过期的数据。
TTL设置原则:
1 2 3 4 5 6 7 8 stringRedisTemplate.opsForValue().set(key, value, 30 , TimeUnit.MINUTES); stringRedisTemplate.opsForValue().set(key, value, 1 , TimeUnit.HOURS); stringRedisTemplate.opsForValue().set(key, value, 24 , TimeUnit.HOURS);
TTL设计要点:
热点数据:TTL较短,保证数据新鲜度
冷数据:TTL较长,减少数据库压力
业务数据:根据业务需求设置合理TTL
手动更新机制
在数据变更时主动删除或更新缓存,保证缓存与数据库的数据一致性。
更新时机:
数据新增时:写入缓存
数据修改时:删除缓存
数据删除时:删除缓存
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 public Result updateShop (Shop shop) { Long id = shop.getId(); updateById(shop); String key = CACHE_SHOP_KEY + id; stringRedisTemplate.delete(key); return Result.ok(); }
2.3.2 数据库缓存不一致解决方案
一致性问题分析
缓存数据源来自数据库,而数据库数据会发生变化。当数据库数据发生变化而缓存未同步时,就会产生一致性问题,用户使用过时数据会影响业务正确性。
三大解决方案对比:
Cache Aside Pattern Read/Write Through Write Behind Caching 旁路缓存模式(推荐)
缓存调用者在更新数据库后手动更新缓存,也称为双写方案。
实现流程:
1 2 3 4 5 6 查询操作: 缓存命中 → 直接返回 缓存未命中 → 查询数据库 → 写入缓存 → 返回结果 更新操作: 更新数据库 → 删除缓存
优点:
✅ 实现简单,控制灵活
✅ 数据一致性较好
✅ 适合读多写少场景
缺点:
适用场景:
读多写少的业务系统
对数据一致性要求较高
开发团队有较强技术能力
读写穿透模式
由缓存系统本身完成数据库与缓存的同步操作。
实现流程:
1 2 3 4 5 查询操作: 缓存未命中 → 缓存系统查询数据库 → 写入缓存 → 返回结果 更新操作: 更新缓存 → 缓存系统同步更新数据库
优点:
✅ 对应用透明,简化开发
✅ 缓存系统统一管理
✅ 数据一致性较好
缺点:
❌ 实现复杂,需要缓存系统支持
❌ 性能开销较大
❌ 强依赖缓存系统稳定性
适用场景:
大型分布式系统
有专业缓存中间件支持
对开发简化要求较高
异步写回模式
调用者只操作缓存,其他线程异步处理数据库,实现最终一致性。
实现流程:
1 2 3 4 5 查询操作: 直接查询缓存 更新操作: 更新缓存 → 异步线程批量写入数据库
优点:
✅ 写入性能极高
✅ 数据库压力小
✅ 适合写密集型应用
缺点:
❌ 数据一致性差
❌ 实现复杂度高
❌ 可能丢失数据
适用场景:
写操作远多于读操作
对数据一致性要求不高
允许短暂数据丢失
方案选择建议:
方案
一致性
性能
复杂度
适用场景
Cache Aside
⭐⭐⭐
⭐⭐⭐
⭐⭐
读多写少
Read/Write Through
⭐⭐⭐⭐
⭐⭐
⭐⭐⭐⭐
大型系统
Write Behind
⭐⭐
⭐⭐⭐⭐
⭐⭐⭐⭐⭐
写密集型
2.3.3 最佳实践方案选择
推荐方案:Cache Aside + 删除缓存策略
综合考虑一致性、性能和实现复杂度,推荐使用Cache Aside模式配合删除缓存策略。
核心设计原则:
删除缓存策略(推荐)
更新数据库时让缓存失效,查询时再更新缓存。
实现流程:
1 2 3 4 更新操作: 1. 更新数据库 2. 删除缓存 3. 等待下次查询时重新加载
优势分析:
✅ 避免无效写操作 :多次更新只需一次删除
✅ 简化实现逻辑 :无需考虑缓存数据格式转换
✅ 降低并发问题 :减少缓存与数据库不一致时间窗口
对比表格:
策略
写操作次数
实现复杂度
一致性风险
推荐度
更新缓存
每次更新都写缓存
高(需处理数据转换)
高(并发更新冲突)
⭐⭐
删除缓存
仅删除,查询时重建
低(简单删除)
低(重建时数据最新)
⭐⭐⭐⭐⭐
先操作数据库,再删除缓存(推荐)
确保数据库操作成功后再删除缓存,避免缓存删除后数据库更新失败的情况。
时序分析:
1 2 线程1:更新数据库 → 删除缓存 线程2:查询缓存未命中 → 查询数据库 → 写入缓存
并发安全性:
即使线程2在线程1删除缓存后查询数据库,获取的也是最新的数据
避免了"删除缓存→数据库更新失败"导致的数据不一致
对比分析:
操作顺序
并发风险
一致性保证
实现复杂度
推荐度
先删缓存再更新数据库
高(其他线程可能写入旧数据)
差
高(需额外锁机制)
⭐⭐
先更新数据库再删缓存
低(数据库已更新)
好
低
⭐⭐⭐⭐⭐
操作原子性保证
确保缓存与数据库操作同时成功或失败,避免中间状态。
单体系统事务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Transactional public Result updateShop (Shop shop) { try { shopMapper.updateById(shop); stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId()); return Result.ok(); } catch (Exception e) { throw new RuntimeException ("更新失败" , e); } }
分布式系统方案:
TCC模式 :Try-Confirm-Cancel三阶段提交
消息队列 :通过消息确保最终一致性
分布式锁 :保证操作顺序性
最终推荐方案:
1 2 3 4 5 6 🎯 最佳实践组合: 1. 采用Cache Aside模式 2. 使用删除缓存策略 3. 先更新数据库再删除缓存 4. 单体系统使用事务保证 5. 分布式系统使用消息队列保证最终一致性
2.4 商户缓存双写一致性实现
2.4.1 实现思路
双写一致性要求
查询时:缓存未命中则查询数据库并写入缓存,设置合理过期时间
更新时:先更新数据库,再删除缓存,确保数据一致性
实现流程图:
1 2 3 4 5 6 7 8 9 10 11 查询流程: 用户请求 → 查询Redis缓存 → 命中?→ 是 → 返回缓存数据 ↓ 否 查询MySQL数据库 ↓ 写入Redis缓存(带TTL) ↓ 返回查询结果 更新流程: 用户请求 → 更新MySQL数据库 → 删除Redis缓存 → 返回更新结果
2.4.2 代码实现
查询方法优化 更新方法实现 Controller层调用 一致性验证 ShopServiceImpl查询方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public Result queryShopById (Long id) { String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } Shop shop = getById(id); if (shop == null ) { return Result.fail("店铺不存在!" ); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); }
ShopServiceImpl更新方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override @Transactional public Result updateShop (Shop shop) { Long id = shop.getId(); if (id == null ) { return Result.fail("店铺id不能为空!" ); } boolean updated = updateById(shop); if (!updated) { return Result.fail("店铺更新失败!" ); } String key = CACHE_SHOP_KEY + id; stringRedisTemplate.delete(key); return Result.ok(); }
ShopController:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @RestController @RequestMapping("/shop") public class ShopController { @GetMapping("/{id}") public Result queryShop (@PathVariable("id") Long id) { return shopService.queryShopById(id); } @PutMapping public Result updateShop (@RequestBody Shop shop) { return shopService.updateShop(shop); } }
测试验证:
测试场景
预期结果
实际验证
首次查询
查询数据库,写入缓存
✅
二次查询
直接返回缓存数据
✅
数据更新
更新数据库,删除缓存
✅
更新后查询
重新查询数据库,写入新缓存
✅
性能对比:
操作类型
直接数据库
带缓存
性能提升
查询操作
150ms
5ms
30倍
更新操作
50ms
55ms
基本持平
代码分析:
通过采用删除缓存策略解决双写问题,当数据更新后删除缓存,后续查询会从MySQL加载最新数据并重新写入缓存,从而避免数据库和缓存不一致的问题。
2.5 缓存穿透问题解决
2.5.1 缓存穿透问题分析
缓存穿透定义
客户端请求的数据在缓存和数据库中都不存在,导致缓存永远无法生效,所有请求都直接打到数据库,可能引发数据库崩溃。
问题场景:
1 2 3 4 5 6 7 8 9 10 11 用户请求ID=99999的商品 ↓ 查询Redis缓存(不存在) ↓ 查询MySQL数据库(不存在) ↓ 返回404错误 ↓ 大量恶意请求重复此过程 ↓ 数据库压力剧增,可能崩溃
风险特征:
🔴 请求的数据在数据库中不存在
🔴 缓存无法命中,失去保护作用
🔴 大量请求直接访问数据库
🔴 可能被恶意利用进行攻击
2.5.2 解决方案对比
两大核心解决方案:
实现原理
当数据库查询结果为空时,仍将空结果缓存到Redis,设置较短的过期时间。
实现流程:
1 2 用户请求 → 查询缓存未命中 → 查询数据库为空 → 缓存空值 → 返回结果 下次请求 → 查询缓存命中(空值)→ 直接返回,不再访问数据库
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public Result queryShopById (Long id) { String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { if ("null" .equals(shopJson)) { return Result.fail("店铺不存在!" ); } Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } Shop shop = getById(id); if (shop == null ) { stringRedisTemplate.opsForValue().set(key, "" , 2 , TimeUnit.MINUTES); return Result.fail("店铺不存在!" ); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); }
方案评估:
维度
评分
说明
实现复杂度
⭐⭐
简单,几行代码即可实现
内存消耗
⭐⭐⭐
需要额外存储空值,但可设置短TTL
防护效果
⭐⭐⭐⭐
有效阻止重复查询不存在数据
数据一致性
⭐⭐⭐
可能存在短暂不一致
实现原理
使用布隆过滤器在查询前进行预判,过滤掉明显不存在的数据请求。
数据结构:
庞大的二进制位数组(BitSet)
多个哈希函数
内存占用极低
实现流程:
1 2 3 用户请求 → 布隆过滤器判断 → 不存在 → 直接返回 ↓ 存在 查询Redis缓存 → 未命中 → 查询数据库
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Component public class BloomFilterService { @Resource private StringRedisTemplate stringRedisTemplate; public void initBloomFilter () { List<Long> allShopIds = shopMapper.selectAllIds(); for (Long id : allShopIds) { addToBloomFilter("shop:bloom:" , id); } } private void addToBloomFilter (String key, Long id) { int [] offset = getBitOffset(id); for (int i : offset) { stringRedisTemplate.opsForValue().setBit(key, i, true ); } } public boolean mightExist (String key, Long id) { int [] offset = getBitOffset(id); for (int i : offset) { if (!stringRedisTemplate.opsForValue().getBit(key, i)) { return false ; } } return true ; } }
方案评估:
维度
评分
说明
实现复杂度
⭐⭐⭐⭐
需要额外组件,算法复杂
内存消耗
⭐⭐⭐⭐⭐
极低的内存占用
防护效果
⭐⭐⭐⭐⭐
完美阻止不存在数据查询
误判率
⭐⭐
存在误判可能,需权衡配置
方案选择建议:
业务场景
推荐方案
理由
中小型系统
缓存空对象
实现简单,快速上线
大型高并发系统
布隆过滤器
内存优化,防护效果更好
数据量巨大
布隆过滤器
内存占用与数据量无关
开发资源有限
缓存空对象
维护成本低
2.6 缓存穿透编码实现
2.6.1 实现思路
原始问题
在原来的逻辑中,如果发现数据在MySQL中不存在,直接返回404,这样会导致缓存穿透问题,大量请求直接到达数据库。
优化思路:
1 2 3 4 5 原逻辑: 查询缓存未命中 → 查询数据库为空 → 直接返回404 → 缓存穿透风险 新逻辑: 查询缓存未命中 → 查询数据库为空 → 缓存空值 → 返回结果 → 下次直接命中缓存
2.6.2 代码实现
ShopServiceImpl缓存穿透防护:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @Override public Result queryShopById (Long id) { if (id == null || id <= 0 ) { return Result.fail("店铺ID非法!" ); } String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { if ("null" .equals(shopJson)) { return Result.fail("店铺不存在!" ); } Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } Shop shop = getById(id); if (shop == null ) { stringRedisTemplate.opsForValue().set(key, "" , 2 , TimeUnit.MINUTES); return Result.fail("店铺不存在!" ); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); }
缓存穿透防护工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 @Component public class CachePenetrationProtection { @Resource private StringRedisTemplate stringRedisTemplate; public <T> T safeQuery (String key, Class<T> type, Supplier<T> dbFallback, Long ttl, TimeUnit unit) { String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { if ("null" .equals(json)) { return null ; } return JSONUtil.toBean(json, type); } T result = dbFallback.get(); if (result == null ) { stringRedisTemplate.opsForValue().set(key, "" , 2 , TimeUnit.MINUTES); } else { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(result), ttl, unit); } return result; } }
防护效果测试:
测试场景
请求参数
预期结果
数据库访问次数
正常查询
id=1
返回店铺信息
1次
不存在查询
id=99999
返回"店铺不存在"
1次
重复不存在查询
id=99999
返回"店铺不存在"
0次(命中缓存空值)
非法参数
id=-1
返回"店铺ID非法"
0次(参数校验拦截)
性能对比:
查询类型
无防护QPS
有防护QPS
数据库压力
不存在数据查询
1000(全部打到DB)
10000+(缓存拦截)
降低90%+
多重防护策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @RestController @RequestMapping("/shop") public class ShopController { @GetMapping("/{id}") public Result queryShop (@PathVariable("id") String idStr) { if (!idStr.matches("\\d+" )) { return Result.fail("店铺ID格式错误!" ); } Long id = Long.valueOf(idStr); if (id > 1000000 ) { return Result.fail("店铺ID超出范围!" ); } return shopService.queryShopById(id); } }
2.6.3 总结与最佳实践
缓存穿透产生原因:
用户请求的数据在缓存和数据库中都不存在
大量请求不断发起,给数据库带来巨大压力
解决方案完整清单:
防护层级
解决方案
实现复杂度
防护效果
应用层
参数格式校验
⭐
基础防护
应用层
ID范围限制
⭐⭐
中级防护
缓存层
缓存空对象
⭐⭐⭐
核心防护
架构层
布隆过滤器
⭐⭐⭐⭐⭐
高级防护
系统层
限流熔断
⭐⭐⭐⭐
兜底防护
推荐组合:
中小型系统:参数校验 + 缓存空对象
大型系统:参数校验 + 布隆过滤器 + 缓存空对象
超高并发:全部方案组合使用
2.7 缓存雪崩问题解决
2.7.1 缓存雪崩问题分析
缓存雪崩 :在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求直接到达数据库,可能瞬间压垮数据库,造成系统级故障。
问题场景 :
1 用户请求 → 缓存失效 → 请求数据库 → 数据库压力过大 → 数据库崩溃 → 服务不可用
风险特征 :
突发性:大量key在同一时间失效
级联性:缓存失效→数据库压力→系统崩溃
恢复难:系统恢复后可能再次雪崩
2.7.2 解决方案对比
过期时间随机化 Redis集群高可用 降级限流策略 多级缓存架构 过期时间随机化
实现原理 :
在基础TTL上添加随机值,分散key的过期时间
代码实现 :
1 2 3 4 5 int baseTtl = 30 ;int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(-5 , 6 );stringRedisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.MINUTES);
优势 :
实现简单,成本低
能有效分散过期时间
对业务无侵入
劣势 :
适用场景 :
适合大多数业务场景,作为基础防护手段
Redis集群高可用
实现原理 :
通过主从复制、哨兵模式、集群模式提高Redis可用性
架构对比 :
方案
可用性
数据安全
复杂度
成本
主从复制
★★☆
★★☆
★☆☆
低
哨兵模式
★★★
★★☆
★★☆
中
Redis Cluster
★★★
★★★
★★★
高
配置示例 (哨兵模式):
1 2 3 4 sentinel monitor mymaster 127.0.0.1 6379 2 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 10000
优势 :
劣势 :
适用场景 :
对可用性要求高的核心业务
降级限流策略
实现原理 :
在缓存失效时,通过降级和限流保护数据库
降级策略 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @SentinelResource(value = "getShop", blockHandler = "handleBlock", fallback = "handleFallback") public Shop getShop (Long id) { return shopService.getById(id); } public Shop handleBlock (Long id, BlockException ex) { log.warn("接口被限流: {}" , id); return getDefaultShop(id); } public Shop handleFallback (Long id, Throwable ex) { log.error("接口降级: {}" , id, ex); return getDefaultShop(id); }
限流算法 :
令牌桶算法:平滑流量
漏桶算法:固定速率处理
计数器算法:简单直接
优势 :
劣势 :
适用场景 :
高并发、大数据量场景
多级缓存架构
架构设计 :
1 用户请求 → CDN缓存 → Nginx缓存 → 本地缓存 → Redis缓存 → 数据库
实现方案 :
缓存层级
技术选型
响应时间
容量
成本
CDN缓存
CloudFlare/阿里云CDN
10-50ms
巨大
高
Nginx缓存
proxy_cache
1-5ms
中等
低
本地缓存
Caffeine/Guava
0.1-1ms
小
极低
Redis缓存
Redis Cluster
1-10ms
大
中
本地缓存实现 (Caffeine):
1 2 3 4 5 6 7 8 9 10 11 12 13 LoadingCache<String, Shop> localCache = Caffeine.newBuilder() .maximumSize(10000 ) .expireAfterWrite(5 , TimeUnit.MINUTES) .build(key -> { return getFromRedis(key); }); public Shop queryShop (Long id) { return localCache.get(CACHE_SHOP_KEY + id); }
优势 :
多层次保护,容错性强
响应速度快,用户体验好
各层缓存互补
劣势 :
适用场景 :
大型互联网应用,对性能和可用性要求极高
2.7.3 最佳实践方案
推荐方案 :过期时间随机化 + Redis集群 + 本地缓存
方案组合 :
基础层 :过期时间随机化(必做)
可用层 :Redis哨兵模式或Cluster(推荐)
加速层 :本地缓存(Caffeine/Guava)
保护层 :降级限流(Sentinel)
实施步骤 :
为所有缓存key添加随机TTL
部署Redis哨兵模式
集成本地缓存框架
配置限流降级规则
监控和告警设置
效果预期 :
缓存雪崩概率降低90%+
系统可用性达到99.9%+
响应时间提升50%+
2.8 缓存击穿问题解决
2.8.1 缓存击穿问题分析
缓存击穿 :也叫热点Key问题,一个被高并发访问并且缓存重建业务较复杂的key突然失效,无数请求瞬间到达数据库,可能造成数据库崩溃。
问题场景 :
1 高并发请求 → 热点key失效 → 大量请求数据库 → 数据库压力激增 → 数据库崩溃
与缓存穿透的区别 :
缓存穿透:查询不存在的数据
缓存击穿:热点key突然失效
缓存雪崩:大量key同时失效
2.8.2 解决方案对比
互斥锁方案
实现原理 :
通过分布式锁保证只有一个线程去重建缓存,其他线程等待
流程图 :
1 2 3 请求1:获取锁 → 查询数据库 → 重建缓存 → 释放锁 → 返回数据 请求2:等待锁 → 从缓存获取 → 返回数据 请求3:等待锁 → 从缓存获取 → 返回数据
代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public Shop queryWithMutex (Long id) { String key = CACHE_SHOP_KEY + id; String lockKey = LOCK_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } boolean isLock = tryLock(lockKey); if (!isLock) { Thread.sleep(50 ); return queryWithMutex(id); } try { shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } Shop shop = getById(id); if (shop == null ) { stringRedisTemplate.opsForValue().set(key, "" , CACHE_NULL_TTL, TimeUnit.MINUTES); return null ; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; } finally { unlock(lockKey); } }
优势 :
劣势 :
性能损耗(串行化)
存在死锁风险
用户体验差(等待)
适用场景 :
对数据一致性要求高的业务场景
逻辑过期方案
实现原理 :
不设置Redis过期时间,在value中存储逻辑过期时间,异步重建缓存
流程图 :
1 2 3 请求1:发现过期 → 获取锁 → 返回旧数据 + 异步重建 请求2:发现过期 → 等待锁 → 返回旧数据 请求3:发现重建完成 → 返回新数据
数据结构设计 :
1 2 3 4 5 @Data public class RedisData { private LocalDateTime expireTime; private Object data; }
代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10 );public Shop queryWithLogicalExpire (Long id) { String key = CACHE_SHOP_KEY + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null ; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())) { return shop; } String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { saveShop2Redis(id, 20L ); } catch (Exception e) { log.error("缓存重建失败" , e); } finally { unlock(lockKey); } }); } return shop; } private void saveShop2Redis (Long id, Long expireSeconds) { Shop shop = getById(id); RedisData redisData = new RedisData (); redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); }
优势 :
性能优秀(无需等待)
用户体验好(立即响应)
无死锁风险
劣势 :
数据一致性弱(可能返回脏数据)
实现复杂
占用额外内存
适用场景 :
对性能要求高,能容忍短暂数据不一致的场景
2.8.3 方案选择建议
选择建议 :根据业务场景和数据一致性要求选择合适的方案
对比分析 :
维度
互斥锁方案
逻辑过期方案
数据一致性
★★★★★
★★☆☆☆
性能表现
★★☆☆☆
★★★★★
实现复杂度
★★☆☆☆
★★★★☆
用户体验
★★☆☆☆
★★★★★
内存占用
★★★★★
★★☆☆☆
死锁风险
存在
不存在
业务场景推荐 :
金融支付 :互斥锁方案(强一致性)
商品详情 :逻辑过期方案(高性能)
用户资料 :互斥锁方案(数据重要)
新闻资讯 :逻辑过期方案(容忍延迟)
混合策略 :
1 2 3 4 5 6 7 public Shop queryShop (Long id, boolean requireConsistency) { if (requireConsistency) { return queryWithMutex(id); } else { return queryWithLogicalExpire(id); } }
2.9 互斥锁方案实现
2.9.1 实现思路
核心思路 :在缓存未命中时,通过分布式锁保证只有一个线程去查询数据库并重建缓存,其他线程等待锁释放后重试,避免大量请求同时打到数据库。
实现流程 :
1 查询缓存 → 缓存未命中 → 获取互斥锁 → 再次检查缓存 → 查询数据库 → 重建缓存 → 释放锁
设计要点 :
双重检查 :获取锁后再次检查缓存,防止重复查询数据库
锁超时 :设置合理的锁过期时间,防止死锁
异常处理 :确保锁最终能被释放
重试机制 :获取锁失败时适当休眠后重试
2.9.2 代码实现
工具类封装 Service层实现 Controller层调用 测试验证 分布式锁工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @Component public class RedisLockUtil { @Resource private StringRedisTemplate stringRedisTemplate; private static final String LOCK_PREFIX = "lock:" ; private static final long DEFAULT_TIMEOUT = 10 ; public boolean tryLock (String key, long timeout) { String lockKey = LOCK_PREFIX + key; Boolean flag = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, Thread.currentThread().getId() + "" , timeout, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } public boolean unlock (String key) { String lockKey = LOCK_PREFIX + key; try { String threadId = Thread.currentThread().getId() + "" ; String value = stringRedisTemplate.opsForValue().get(lockKey); if (threadId.equals(value)) { return BooleanUtil.isTrue(stringRedisTemplate.delete(lockKey)); } return false ; } catch (Exception e) { log.error("释放锁失败: {}" , lockKey, e); return false ; } } public boolean tryLockWithRetry (String key, long timeout, int maxRetry, long retryInterval) { for (int i = 0 ; i < maxRetry; i++) { if (tryLock(key, timeout)) { return true ; } if (i < maxRetry - 1 ) { try { Thread.sleep(retryInterval); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false ; } } } return false ; } }
Service层完整实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 @Service @Slf4j public class ShopServiceImpl extends ServiceImpl <ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedisLockUtil redisLockUtil; private static final String CACHE_SHOP_KEY = "cache:shop:" ; private static final String LOCK_SHOP_KEY = "lock:shop:" ; private static final Long CACHE_SHOP_TTL = 30L ; private static final Long CACHE_NULL_TTL = 2L ; @Override public Shop queryWithMutex (Long id) { String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { log.debug("缓存命中: {}" , key); return JSONUtil.toBean(shopJson, Shop.class); } if (shopJson != null ) { log.debug("命中空值缓存: {}" , key); return null ; } String lockKey = LOCK_SHOP_KEY + id; Shop shop = null ; try { boolean isLock = redisLockUtil.tryLockWithRetry(lockKey, 10L , 3 , 50L ); if (!isLock) { log.warn("获取锁失败,返回兜底数据: {}" , lockKey); return getDefaultShop(id); } log.debug("获取锁成功: {}" , lockKey); shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { log.debug("双重检查命中缓存: {}" , key); return JSONUtil.toBean(shopJson, Shop.class); } log.debug("查询数据库: {}" , id); shop = getById(id); if (shop == null ) { log.debug("数据不存在,写入空值缓存: {}" , key); stringRedisTemplate.opsForValue().set(key, "" , CACHE_NULL_TTL, TimeUnit.MINUTES); return null ; } log.debug("写入缓存: {}" , key); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (Exception e) { log.error("查询商户异常: {}" , id, e); throw new RuntimeException ("查询商户失败" , e); } finally { redisLockUtil.unlock(lockKey); log.debug("释放锁: {}" , lockKey); } return shop; } private Shop getDefaultShop (Long id) { Shop defaultShop = new Shop (); defaultShop.setId(id); defaultShop.setName("默认商户" ); defaultShop.setTypeId(1L ); return defaultShop; } }
Controller层调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @RestController @RequestMapping("/shop") @Api(tags = "商户管理") public class ShopController { @Resource private IShopService shopService; @GetMapping("/{id}") @ApiOperation("根据ID查询商户") public Result queryShop (@PathVariable("id") Long id) { if (id == null || id <= 0 ) { return Result.fail("商户ID不能为空" ); } try { Shop shop = shopService.queryWithMutex(id); if (shop == null ) { return Result.fail("商户不存在" ); } return Result.ok(shop); } catch (Exception e) { log.error("查询商户失败: {}" , id, e); return Result.fail("查询商户失败,请稍后重试" ); } } }
并发测试验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 @SpringBootTest @Slf4j public class CacheBreakdownTest { @Resource private IShopService shopService; @Test public void testCacheBreakdown () throws InterruptedException { Long shopId = 1L ; int threadCount = 100 ; CountDownLatch latch = new CountDownLatch (threadCount); AtomicInteger successCount = new AtomicInteger (0 ); AtomicInteger dbQueryCount = new AtomicInteger (0 ); StringRedisTemplate stringRedisTemplate = SpringContextUtil.getBean(StringRedisTemplate.class); stringRedisTemplate.delete("cache:shop:" + shopId); log.info("开始并发测试,线程数:{},商户ID:{}" , threadCount, shopId); long startTime = System.currentTimeMillis(); for (int i = 0 ; i < threadCount; i++) { new Thread (() -> { try { Shop shop = shopService.queryWithMutex(shopId); if (shop != null ) { successCount.incrementAndGet(); } } catch (Exception e) { log.error("查询异常" , e); } finally { latch.countDown(); } }).start(); } latch.await(); long endTime = System.currentTimeMillis(); log.info("并发测试完成" ); log.info("总耗时:{}ms" , (endTime - startTime)); log.info("成功查询数:{}" , successCount.get()); log.info("QPS:{}" , threadCount * 1000 / (endTime - startTime)); String cachedData = stringRedisTemplate.opsForValue().get("cache:shop:" + shopId); Assertions.assertNotNull(cachedData, "缓存应该已重建" ); } }
2.9.3 性能对比与最佳实践
性能表现 :互斥锁方案在并发测试中表现优异,能有效防止缓存击穿问题
测试对比 :
方案
并发数
数据库查询次数
平均响应时间
数据一致性
无保护
100
100次
50ms
不一致
互斥锁
100
1次
80ms
强一致
逻辑过期
100
1次
10ms
最终一致
最佳实践 :
锁粒度控制 :按业务维度加锁,避免全局锁
超时设置 :锁超时时间要大于数据库查询时间
重试机制 :获取锁失败时要有重试和兜底策略
监控告警 :监控锁等待时间和获取成功率
性能优化 :结合本地缓存减少Redis访问
2.10 逻辑过期方案实现
2.10.1 实现思路
核心思路 :不设置Redis过期时间,在value中存储逻辑过期时间,当数据过期时异步重建缓存,立即返回旧数据,保证用户体验。
实现流程 :
1 查询缓存 → 命中数据 → 检查逻辑过期时间 → 未过期直接返回 → 过期则异步重建 → 返回旧数据
设计要点 :
异步重建 :开启独立线程进行缓存重建,不阻塞用户请求
逻辑过期 :在value中存储过期时间,不依赖Redis TTL
锁控制 :通过分布式锁保证只有一个线程进行重建
线程池管理 :使用线程池管理异步任务,避免线程爆炸
2.10.2 代码实现
数据模型设计 缓存预热工具 Service层实现 测试验证 逻辑过期数据模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Data @NoArgsConstructor @AllArgsConstructor public class RedisData { private LocalDateTime expireTime; private Object data; public static RedisData of (Object data, Long expireSeconds) { RedisData redisData = new RedisData (); redisData.setData(data); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); return redisData; } public boolean isExpired () { return expireTime.isBefore(LocalDateTime.now()); } }
缓存预热工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 @Component @Slf4j public class CachePreheatUtil { @Resource private StringRedisTemplate stringRedisTemplate; @Resource private IShopService shopService; private static final String CACHE_SHOP_KEY = "cache:shop:" ; private static final Long LOGICAL_EXPIRE_TIME = 20L ; public void preheatShop (Long shopId, Long expireSeconds) { String key = CACHE_SHOP_KEY + shopId; try { Shop shop = shopService.getById(shopId); if (shop == null ) { log.warn("商户不存在,跳过预热: {}" , shopId); return ; } RedisData redisData = RedisData.of(shop, expireSeconds); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); log.info("缓存预热成功: {}, 过期时间: {}秒" , key, expireSeconds); } catch (Exception e) { log.error("缓存预热失败: {}" , shopId, e); } } public void preheatHotShops (List<Long> shopIds, Long expireSeconds) { log.info("开始批量预热热门商户,数量: {}" , shopIds.size()); for (Long shopId : shopIds) { try { preheatShop(shopId, expireSeconds); Thread.sleep(100 ); } catch (Exception e) { log.error("预热商户失败: {}" , shopId, e); } } log.info("批量预热完成" ); } }
Service层完整实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 @Service @Slf4j public class ShopServiceImpl extends ServiceImpl <ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedisLockUtil redisLockUtil; private static final String CACHE_SHOP_KEY = "cache:shop:" ; private static final String LOCK_SHOP_KEY = "lock:shop:" ; private static final Long LOGICAL_EXPIRE_TIME = 20L ; private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors(), r -> { Thread thread = new Thread (r); thread.setName("cache-rebuild-" + thread.getId()); thread.setDaemon(true ); return thread; } ); @Override public Shop queryWithLogicalExpire (Long id) { String key = CACHE_SHOP_KEY + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { log.debug("缓存未命中,直接返回null: {}" , key); return null ; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); if (!redisData.isExpired()) { log.debug("缓存未过期,直接返回: {}" , key); return shop; } log.debug("缓存已过期,开始异步重建: {}" , key); String lockKey = LOCK_SHOP_KEY + id; boolean isLock = redisLockUtil.tryLock(lockKey, 10L ); if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { log.info("开始异步重建缓存: {}" , key); saveShop2Redis(id, LOGICAL_EXPIRE_TIME); log.info("异步重建缓存完成: {}" , key); } catch (Exception e) { log.error("异步重建缓存失败: {}" , key, e); } finally { redisLockUtil.unlock(lockKey); } }); } log.debug("返回旧数据: {}" , key); return shop; } public void saveShop2Redis (Long id, Long expireSeconds) { try { Shop shop = getById(id); if (shop == null ) { log.warn("商户不存在,跳过缓存重建: {}" , id); return ; } RedisData redisData = RedisData.of(shop, expireSeconds); String key = CACHE_SHOP_KEY + id; stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); log.debug("逻辑过期缓存重建成功: {}, 过期时间: {}秒" , key, expireSeconds); } catch (Exception e) { log.error("逻辑过期缓存重建失败: {}" , id, e); throw new RuntimeException ("缓存重建失败" , e); } } }
并发测试验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 @SpringBootTest @Slf4j public class LogicalExpireTest { @Resource private IShopService shopService; @Resource private CachePreheatUtil cachePreheatUtil; @Resource private StringRedisTemplate stringRedisTemplate; @Test public void testLogicalExpirePerformance () throws InterruptedException { Long shopId = 1L ; int threadCount = 100 ; CountDownLatch latch = new CountDownLatch (threadCount); AtomicInteger successCount = new AtomicInteger (0 ); AtomicInteger rebuildCount = new AtomicInteger (0 ); cachePreheatUtil.preheatShop(shopId, -1L ); log.info("开始逻辑过期性能测试,线程数: {},商户ID: {}" , threadCount, shopId); long startTime = System.currentTimeMillis(); for (int i = 0 ; i < threadCount; i++) { new Thread (() -> { try { long threadStart = System.currentTimeMillis(); Shop shop = shopService.queryWithLogicalExpire(shopId); long threadEnd = System.currentTimeMillis(); if (shop != null ) { successCount.incrementAndGet(); log.debug("线程{}查询成功,耗时: {}ms" , Thread.currentThread().getId(), (threadEnd - threadStart)); } } catch (Exception e) { log.error("查询异常" , e); } finally { latch.countDown(); } }).start(); } latch.await(); long endTime = System.currentTimeMillis(); Thread.sleep(2000 ); String cachedData = stringRedisTemplate.opsForValue().get("cache:shop:" + shopId); Assertions.assertNotNull(cachedData, "缓存应该已重建" ); RedisData redisData = JSONUtil.toBean(cachedData, RedisData.class); Assertions.assertFalse(redisData.isExpired(), "重建后的缓存应该未过期" ); log.info("逻辑过期测试完成" ); log.info("总耗时: {}ms" , (endTime - startTime)); log.info("成功查询数: {}" , successCount.get()); log.info("QPS: {}" , threadCount * 1000 / (endTime - startTime)); log.info("平均响应时间: {}ms" , (endTime - startTime) / threadCount); } @Test public void testCacheRebuild () throws InterruptedException { Long shopId = 2L ; cachePreheatUtil.preheatShop(shopId, 1L ); Thread.sleep(1500 ); Shop shop = shopService.queryWithLogicalExpire(shopId); Assertions.assertNotNull(shop, "应该返回旧数据" ); Thread.sleep(2000 ); String cachedData = stringRedisTemplate.opsForValue().get("cache:shop:" + shopId); RedisData redisData = JSONUtil.toBean(cachedData, RedisData.class); Assertions.assertFalse(redisData.isExpired(), "缓存应该已重建" ); log.info("缓存重建验证成功" ); } }
2.10.3 性能对比与最佳实践
性能表现 :逻辑过期方案在高并发场景下表现优异,平均响应时间比互斥锁方案提升80%+
性能对比 :
指标
互斥锁方案
逻辑过期方案
提升幅度
平均响应时间
80ms
12ms
↓85%
并发处理能力
中等
优秀
↑300%
数据一致性
强一致
最终一致
-
用户体验
有等待
无等待
↑100%
实现复杂度
简单
复杂
-
内存占用
低
高
-
最佳实践 :
预热策略 :系统启动时预热热门数据,避免冷启动问题
过期时间 :根据业务特点设置合理的逻辑过期时间
线程池配置 :根据服务器性能合理配置线程池大小
监控告警 :监控缓存命中率和重建频率
降级策略 :异步重建失败时要有降级方案
适用场景 :
读多写少的业务场景
对性能要求极高的应用
能容忍短暂数据不一致的业务
高并发查询的热点数据
2.11 Redis工具类封装
2.11.1 需求分析
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
基础功能
方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
查询功能
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
2.11.2 代码实现
CacheClient工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 @Slf4j @Component public class CacheClient { private final StringRedisTemplate stringRedisTemplate; private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10 ); public CacheClient (StringRedisTemplate stringRedisTemplate) { this .stringRedisTemplate = stringRedisTemplate; } public void set (String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); } 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))); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } public <R,ID> R queryWithPassThrough ( String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, type); } if (json != null ) { return null ; } R r = dbFallback.apply(id); if (r == null ) { stringRedisTemplate.opsForValue().set(key, "" , CACHE_NULL_TTL, TimeUnit.MINUTES); return null ; } this .set(key, r, time, unit); return r; }
逻辑过期查询方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public <R, ID> R queryWithLogicalExpire ( String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null ; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime = redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())) { return r; } String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { R newR = dbFallback.apply(id); this .setWithLogicalExpire(key, newR, time, unit); } catch (Exception e) { throw new RuntimeException (e); }finally { unlock(lockKey); } }); } return r; }
互斥锁查询方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public <R, ID> R queryWithMutex ( String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, type); } if (shopJson != null ) { return null ; } String lockKey = LOCK_SHOP_KEY + id; R r = null ; try { boolean isLock = tryLock(lockKey); if (!isLock) { Thread.sleep(50 ); return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit); } r = dbFallback.apply(id); if (r == null ) { stringRedisTemplate.opsForValue().set(key, "" , CACHE_NULL_TTL, TimeUnit.MINUTES); return null ; } this .set(key, r, time, unit); } catch (InterruptedException e) { throw new RuntimeException (e); }finally { unlock(lockKey); } return r; }
锁工具方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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); } }
2.11.3 使用示例
ShopServiceImpl中的使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Resource private CacheClient cacheClient;@Override public Result queryById (Long id) { Shop shop = cacheClient .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this ::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES); if (shop == null ) { return Result.fail("店铺不存在!" ); } return Result.ok(shop); }
三种方案对比
方案
适用场景
优点
缺点
缓存穿透保护
数据不存在场景
实现简单,有效防止穿透
有短暂空值缓存
互斥锁方案
强一致性要求
数据一致性强
性能较差,有等待
逻辑过期方案
高性能要求
性能优秀,无等待
实现复杂,可能返回旧数据
2.11.4 最佳实践总结
推荐使用 :CacheClient工具类封装了三种缓存策略,可根据业务场景灵活选择
选择建议 :
常规查询 :优先使用queryWithPassThrough(缓存穿透保护)
热点数据 :使用queryWithLogicalExpire(逻辑过期方案)
关键数据 :使用queryWithMutex(互斥锁方案)
使用优势 :
✅ 代码复用性高,避免重复实现
✅ 策略灵活,可根据业务场景选择
✅ 封装完善,包含异常处理和日志
✅ 性能优化,支持异步重建和双重检查
3. 优惠券秒杀
3.1 redis实现全局唯一ID
3.1.1 全局唯一ID生成
每个店铺都可以发布优惠券,当用户抢购时,就会生成订单并保存到tb_voucher_order表中。
数据库自增ID存在的问题
问题
描述
影响
规律性明显
ID按顺序递增,容易被猜测
泄露业务数据
单表限制
MySQL单表容量不宜超过500W
制约业务扩展
分库分表困难
分布式环境下ID冲突
系统复杂度增加
全局ID生成器要求
全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具
特性
要求
实现思路
唯一性
全局唯一
时间戳+序列号
高可用
99.99%可用
Redis集群
高性能
10W+QPS
内存操作
递增性
趋势递增
时间戳保证
安全性
无规则
拼接随机位
ID结构设计
ID组成结构 :符号位 + 时间戳 + 序列号,总计64位
1 2 3 4 ┌──符号位──┬───────时间戳───────┬───────序列号───────┐ │ 1bit │ 31bit │ 32bit │ │ 永远0 │ 秒级时间戳(69年) │ 秒内计数器(2^32) │ └──────────┴─────────────────┴─────────────────┘
设计优势 :
✅ 符号位 :1bit,永远为0,保证ID为正数
✅ 时间戳 :31bit,以秒为单位,可以使用69年
✅ 序列号 :32bit,秒内的计数器,支持每秒产生2^32个不同ID
✅ 安全性 :不直接使用Redis自增数值,避免泄露业务量
3.1.2 Redis实现方案
RedisIdWorker工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Component public class RedisIdWorker { private static final long BEGIN_TIMESTAMP = 1640995200L ; private static final int COUNT_BITS = 32 ; private StringRedisTemplate stringRedisTemplate; public RedisIdWorker (StringRedisTemplate stringRedisTemplate) { this .stringRedisTemplate = stringRedisTemplate; } public long nextId (String keyPrefix) { LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd" )); Long count = stringRedisTemplate.opsForValue() .increment("icr:" + keyPrefix + ":" + date); return timestamp << COUNT_BITS | count; } }
实现原理解析
步骤
操作
说明
1
生成时间戳
当前时间 - 起始时间(2022-01-01)
2
生成序列号
Redis自增,按天分组避免溢出
3
位运算拼接
timestamp << 32 | count
关键设计 :
✅ 时间戳基准 :使用2022-01-01作为起始时间,支持69年使用期
✅ 按天分片 :Redis key包含日期,避免自增数值过大
✅ 位运算优化 :使用位移和或运算快速拼接ID
✅ 线程安全 :Redis自增操作保证并发安全
性能测试代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Test void testIdWorker () throws InterruptedException { CountDownLatch latch = new CountDownLatch (300 ); Runnable task = () -> { for (int i = 0 ; i < 100 ; i++) { long id = redisIdWorker.nextId("test" ); System.out.println("id = " + id); } latch.countDown(); }; long begin = System.currentTimeMillis(); for (int i = 0 ; i < 300 ; i++) { es.submit(task); } latch.await(); long end = System.currentTimeMillis(); System.out.println("time = " + (end - begin)); }
测试结果 :
🚀 生成速度 :3万个ID仅需几百毫秒
🚀 并发安全 :多线程环境下无重复ID
🚀 趋势递增 :ID整体呈递增趋势
3.1.3 方案对比总结
方案
实现复杂度
性能
可用性
适用场景
Redis方案
⭐⭐ 简单
⭐⭐⭐⭐⭐ 10W+QPS
⭐⭐⭐⭐ 主从架构
中小型系统
雪花算法
⭐⭐⭐ 中等
⭐⭐⭐⭐⭐ 更高
⭐⭐ 依赖时钟
大型分布式系统
数据库自增
⭐ 最简单
⭐⭐ 千级QPS
⭐⭐ 单点故障
单机系统
UUID
⭐ 简单
⭐⭐⭐ 中等
⭐⭐⭐⭐⭐ 完全分布式
对顺序无要求场景
3.2 优惠券管理
3.2.1 优惠券类型设计
优惠券业务模型
类型
特点
使用场景
表结构
普通券
优惠力度小,任意领取
日常促销
tb_voucher
秒杀券
优惠力度大,限时限量
引流获客
tb_voucher + tb_seckill_voucher
表结构设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 tb_voucher ( id bigint PRIMARY KEY , shop_id bigint , title varchar (255 ), sub_title varchar (255 ), rules varchar (1024 ), pay_value bigint , actual_value bigint , type tinyint, status tinyint, create_time datetime, update_time datetime ); tb_seckill_voucher ( voucher_id bigint PRIMARY KEY , stock int , create_time datetime, begin_time datetime, end_time datetime );
3.2.2 优惠券发布实现
Controller层接口
1 2 3 4 5 6 7 8 9 10 @PostMapping public Result addVoucher (@RequestBody Voucher voucher) { voucherService.save(voucher); return Result.ok(voucher.getId()); }
Controller层接口
1 2 3 4 5 6 7 8 9 10 @PostMapping("seckill") public Result addSeckillVoucher (@RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); }
Service层实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @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); stringRedisTemplate.opsForValue() .set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); }
设计要点 :
✅ 事务控制 :使用@Transactional保证数据一致性
✅ Redis同步 :将库存信息缓存到Redis,提升查询性能
✅ 时间窗口 :设置秒杀开始和结束时间
✅ 库存隔离 :秒杀券有独立库存管理
用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill, 请求方式POST,JSON数据如下
1 2 3 4 5 6 7 8 9 10 11 12 { "shopId" : 1 , "title" : "100元代金券" , "subTitle" : "周一至周五可用" , "rules" : "全场通用\\n无需预约\\n可无限叠加" , "payValue" : 8000 , "actualValue" : 10000 , "type" : 1 , "stock" : 100 , "beginTime" : "2022-01-01T00:00:00" , "endTime" : "2022-10-31T23:59:59" }
3.3 秒杀下单实现
3.3.1 业务需求分析
秒杀下单核心逻辑
1 2 3 4 5 6 7 8 9 10 11 graph TD A[用户点击抢购] --> B[查询优惠券信息] B --> C{秒杀是否开始?} C -->|未开始| D[返回失败:秒杀尚未开始] C -->|已开始| E{秒杀是否结束?} E -->|已结束| F[返回失败:秒杀已结束] E -->|进行中| G{库存是否充足?} G -->|不足| H[返回失败:库存不足] G -->|充足| I[扣减库存] I --> J[创建订单] J --> K[返回订单ID]
下单校验规则
校验项
判断条件
失败提示
秒杀时间
beginTime > 当前时间
秒杀尚未开始!
秒杀时间
endTime < 当前时间
秒杀已结束!
库存检查
stock < 1
库存不足!
3.3.2 基础实现方案
controller层调用 Service Service层实现 问题分析 VoucherController
1 2 3 4 5 6 7 8 9 10 @RestController @RequestMapping("/voucher-order") public class VoucherOrderController { @Autowired private IVoucherOrderService voucherOrderService; @PostMapping("/seckill/{id}") public Result seckillVoucher (@PathVariable("id") Long voucherId) { return voucherOrderService.seckillVoucher(voucherId); } }
VoucherOrderService
1 2 3 public interface IVoucherOrderService extends IService <VoucherOrder> { Result seckillVoucher (Long voucherId) ; }
VoucherOrderServiceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 @Autowired private ISeckillVoucherService seckillVoucherService;@Autowired private RedisIdWorker redisIdWorker;@Override public Result seckillVoucher (Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始!" ); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束!" ); } if (voucher.getStock() < 1 ) { return Result.fail("库存不足!" ); } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .update(); if (!success) { return Result.fail("库存不足!" ); } VoucherOrder voucherOrder = new VoucherOrder (); long orderId = redisIdWorker.nextId("order" ); voucherOrder.setId(orderId); Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); }
当前方案存在的问题
问题
现象
原因
影响
超卖问题
库存为负
并发扣减库存
商家损失
重复下单
同一用户多单
并发创建订单
用户体验差
性能瓶颈
响应慢
数据库压力大
系统崩溃
问题根因 :
❌ 无并发控制 :多个线程同时扣减库存
❌ 无幂等保障 :同一用户可重复下单
❌ 数据库压力大 :所有操作都走数据库
3.4 库存超卖问题分析
并发场景下的超卖问题
假设库存为1,同时有100个线程并发下单:
1 2 3 4 5 时间点 | 线程1 | 线程2 | 线程3 | ... | 库存 -------|-------|-------|-------|-----|----- t1 | 查询库存=1 | 查询库存=1 | 查询库存=1 | ... | 1 t2 | 判断库存>0✅ | 判断库存>0✅ | 判断库存>0✅ | ... | 1 t3 | 扣减库存 | 扣减库存 | 扣减库存 | ... | 1→0→-1→-2...
问题本质 :
❌ 读-改-写 操作非原子性
❌ 并发控制缺失 导致数据竞争
❌ 库存校验 与库存扣减 分离
悲观锁 vs 乐观锁
悲观锁 :假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作
乐观锁 :认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
方案
实现方式
优点
缺点
适用场景
悲观锁
synchronized/ReentrantLock
简单易理解
性能差,阻塞严重
并发量低的场景
乐观锁
版本号机制/CAS
性能好,无阻塞
实现复杂,ABA问题
并发量高的场景
乐观锁实现原理 :
1 2 3 4 5 6 7 8 9 UPDATE tb_seckill_voucher SET stock = stock - 1 , version = version + 1 WHERE voucher_id = ? AND version = ?UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE voucher_id = ? AND stock > 0
乐观锁方案演进
1 2 3 4 5 boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .eq("stock" , voucher.getStock()) .update();
问题分析 :
❌ 成功率极低 :100个线程同时拿到stock=100,只有1个能成功
❌ 重试压力大 :失败线程需要重新查询版本号再重试
❌ 用户体验差 :大量请求返回失败
1 2 3 4 5 boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .gt("stock" , 0 ) .update();
优势分析 :
✅ 成功率高 :只要还有库存就能成功
✅ 实现简单 :无需维护版本号
✅ 性能优秀 :无自旋重试,一次操作完成
3.5 一人一单问题
需求分析
问题背景 :
🎯 营销目的 :优惠券用于引流获客,需要控制成本
🎯 公平性 :防止黄牛党恶意刷单
🎯 用户体验 :让更多用户享受到优惠
实现思路 :
1 用户下单前 → 查询该用户是否已购买 → 已购买则拒绝 → 未购买则允许下单
并发场景下的问题
假设用户A同时发起5个请求:
1 2 3 4 5 时间点 | 请求1 | 请求2 | 请求3 | 请求4 | 请求5 -------|-------|-------|-------|-------|------ t1 | 查询订单=0✅ | 查询订单=0✅ | 查询订单=0✅ | 查询订单=0✅ | 查询订单=0✅ t2 | 创建订单 | 创建订单 | 创建订单 | 创建订单 | 创建订单 t3 | 成功 | 成功 | 成功 | 成功 | 成功
问题本质 :
❌ 查询-判断-创建 操作非原子性
❌ 无并发控制 导致重复下单
❌ 数据库唯一约束 无法防止并发插入
悲观锁解决方案
核心思路 :对用户ID 加锁,保证同一用户并发请求串行处理
代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 @Override public Result seckillVoucher (Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始!" ); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束!" ); } if (voucher.getStock() < 1 ) { return Result.fail("库存不足!" ); } Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { return createVoucherOrder(voucherId); } } private Result createVoucherOrder (Long voucherId) { Long userId = UserHolder.getUser().getId(); int count = query().eq("voucher_id" , voucherId).eq("user_id" , userId).count(); if (count > 0 ) { return Result.fail("你已经抢过优惠券了哦" ); } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .gt("stock" , 0 ) .update(); if (!success) { return Result.fail("库存不足" ); } VoucherOrder voucherOrder = new VoucherOrder (); long orderId = redisIdWorker.nextId("order" ); Long id = UserHolder.getUser().getId(); voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(id); save(voucherOrder); return Result.ok(orderId); }
关键设计 :
✅ 锁粒度 :按用户ID加锁,不同用户互不影响
✅ 字符串常量池 :使用intern()确保同一把锁
✅ 事务边界 :锁要包裹整个事务操作
事务与锁的协同机制
核心问题 :Spring事务与JVM锁的生命周期不一致
问题根源 :
直接通过this调用同类方法会导致Spring事务失效,因为事务是通过AOP代理机制实现的。Spring的事务管理基于动态代理,只有通过代理对象调用的方法才能被事务拦截器处理。
生命周期冲突 :
1 2 3 4 5 6 @Transactional public Result method () { synchronized (lock) { } }
解决方案 :获取Spring代理对象确保事务生效
使用AopContext.currentProxy()获取当前代理对象,通过代理对象调用事务方法,确保锁在事务提交后才释放。
技术实现要点 :
启用暴露代理:@EnableAspectJAutoProxy(exposeProxy = true)
接口定义:在IVoucherOrderService接口中声明createVoucherOrder方法
代理调用:通过代理对象调用确保事务拦截器生效
解决方案 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public Result seckillVoucher (Long voucherId) { Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } } @Transactional public Result createVoucherOrder (Long voucherId) { Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()){ int count = query().eq("user_id" , userId).eq("voucher_id" , voucherId).count(); if (count > 0 ) { return Result.fail("用户已经购买过一次!" ); } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId).gt("stock" , 0 ) .update(); if (!success) { return Result.fail("库存不足!" ); } VoucherOrder voucherOrder = new VoucherOrder (); long orderId = redisIdWorker.nextId("order" ); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); }
引入配置和依赖
1 2 3 4 5 <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
1 2 3 4 5 6 7 8 9 10 @MapperScan("com.hmdp.mapper") @SpringBootApplication @EnableAspectJAutoProxy(exposeProxy = true) public class HmDianPingApplication { public static void main (String[] args) { SpringApplication.run(HmDianPingApplication.class, args); } }
3.6 集群环境下的并发问题
集群模式下的新问题
JVM锁失效 :集群环境下,每个JVM实例有自己的锁
场景复现 :
1 2 用户A的请求 → Nginx负载均衡 → 8081端口(JVM锁生效) 用户A的请求 → Nginx负载均衡 → 8082端口(JVM锁失效)
问题本质 :
❌ JVM锁作用域 :只在单个JVM实例内有效
❌ 负载均衡 :同一用户的请求可能分发到不同实例
❌ 分布式环境 :需要分布式锁 解决方案
分布式锁核心要求
特性
要求
实现方案
互斥性
同一时间只有一个客户端能获取锁
Redis SETNX
安全性
锁只能被持有者释放
唯一标识 + Lua脚本
死锁避免
锁必须有超时时间
Redis EXPIRE
可用性
高可用的锁服务
Redis集群
可重入性
同一客户端可重复获取锁
ThreadLocal + 计数器
Redis分布式锁实现
基本命令 :
1 2 3 4 5 6 7 8 9 SET lock_key unique_value NX EX 30 if redis.call("get" , KEYS[1]) == ARGV[1] then return redis.call("del" , KEYS[1]) else return 0 end
实现优势 :
✅ 高性能 :Redis内存操作,10W+QPS
✅ 高可用 :Redis主从架构,故障自动切换
✅ 易实现 :命令简单,客户端支持好
✅ 可扩展 :支持RedLock算法,多Redis实例
有关锁失效原因分析 :这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)
4. 分布式锁
4.1 分布式锁概述
什么是分布式锁
分布式锁 :在分布式系统或集群模式下,多个进程可见且互斥的锁机制
核心思想 :所有节点使用同一把锁 ,确保程序串行执行
与JVM锁的区别 :
1 2 JVM锁:只在单个JVM实例内有效(synchronized、ReentrantLock) 分布式锁:在多个节点间共享,所有实例都能感知锁状态
分布式锁必备特性
特性
说明
实现方案
互斥性
同一时间只有一个客户端能获取锁
Redis SETNX
可见性
所有节点都能感知锁状态变化
Redis发布订阅
高可用
锁服务不易崩溃,故障可恢复
Redis集群/哨兵
高性能
加锁/解锁操作响应快
内存操作
安全性
锁只能被持有者释放
唯一标识验证
死锁避免
锁必须有超时时间
TTL过期机制
常见分布式锁实现
方案
优点
缺点
适用场景
MySQL
实现简单,事务支持
性能差,锁表风险
低频操作
Redis
高性能,10W+QPS
需要处理锁超时
高频并发
Zookeeper
强一致性,Watch机制
实现复杂,性能一般
强一致性要求
企业级选择 :
✅ Redis :99%场景的首选(性能+可用性平衡)
✅ Redisson :Java生态最成熟的分布式锁框架
4.2 Redis分布式锁实现
Redis锁实现原理
获取锁 :
1 2 SET lock_key unique_value NX EX 30
参数说明 :
NX:key不存在时才设置(互斥性)
EX 30:30秒自动过期(死锁避免)
unique_value:线程唯一标识(安全性)
释放锁 :
1 2 3 4 5 6 if redis.call("get" , KEYS[1 ]) == ARGV[1 ] then return redis.call("del" , KEYS[1 ]) else return 0 end
SimpleRedisLock实现
锁接口定义 :
1 2 3 4 5 6 7 8 9 10 11 12 13 public interface ILock { boolean tryLock (long timeoutSec) ; void unlock () ; }
核心实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class SimpleRedisLock implements ILock { private static final String KEY_PREFIX = "lock:" ; private String name; private StringRedisTemplate stringRedisTemplate; public SimpleRedisLock (String name, StringRedisTemplate stringRedisTemplate) { this .name = name; this .stringRedisTemplate = stringRedisTemplate; } @Override public boolean tryLock (long timeoutSec) { long threadId = Thread.currentThread().getId(); Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "" , timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } @Override public void unlock () { stringRedisTemplate.delete(KEY_PREFIX + name); } }
秒杀业务改造
集成分布式锁 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Override public Result seckillVoucher (Long voucherId) { Long userId = UserHolder.getUser().getId(); SimpleRedisLock lock = new SimpleRedisLock ("order:" + userId, stringRedisTemplate); boolean isLock = lock.tryLock(1200 ); if (!isLock) { return Result.fail("不允许重复下单" ); } try { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } finally { lock.unlock(); } }
使用Jmeter进行压力测试,请求头中携带登录用户的token,最终只能抢到一张优惠券
锁误删问题分析
问题场景 :线程阻塞导致锁超时,其他线程获取锁后,原线程恢复误删锁
问题复现 :
1 2 3 4 5 6 时间线: t1: 线程A获取锁(锁超时30s) t2: 线程A业务阻塞(超过30s) t3: 锁自动过期释放 t4: 线程B获取锁成功 t5: 线程A恢复,执行删锁操作(误删线程B的锁)
解决方案 :
✅ 唯一标识 :每个线程使用不同的标识
✅ 验证机制 :删除前验证锁的持有者
✅ 原子操作 :使用Lua脚本保证验证+删除的原子性
4.3 分布式锁演进
版本一:基础实现
问题 :释放锁时可能误删其他线程的锁
1 2 3 4 5 6 Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS); stringRedisTemplate.delete(KEY_PREFIX + name);
版本二:线程标识
实现逻辑 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class SimpleRedisLock implements ILock { private static final String KEY_PREFIX = "lock:" ; private static final String ID_PREFIX = UUID.randomUUID().toString(true ) + "-" ; private String name; private StringRedisTemplate stringRedisTemplate; @Override public boolean tryLock (long timeoutSec) { String threadId = ID_PREFIX + Thread.currentThread().getId(); Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } @Override public void unlock () { String threadId = ID_PREFIX + Thread.currentThread().getId(); String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name); if (threadId.equals(id)) { stringRedisTemplate.delete(KEY_PREFIX + name); } } }
新问题 :验证和删除非原子性 ,仍存在竞态条件
版本三:Lua脚本
Lua脚本实现 :
1 2 3 4 5 6 if redis.call("get" , KEYS[1 ]) == ARGV[1 ] then return redis.call("del" , KEYS[1 ]) else return 0 end
Java调用 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static { UNLOCK_SCRIPT = new DefaultRedisScript <>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource ("unlock.lua" )); UNLOCK_SCRIPT.setResultType(Long.class); } @Override public void unlock () { stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId() ); }
4.4 Lua脚本详解
Redis Lua脚本基础
Lua脚本 :在Redis服务器端执行的脚本,保证多条命令原子性
基本语法 :
1 2 3 4 5 6 7 8 9 10 redis.call('命令名称' , 'key' , '其它参数' , ...) redis.call('set' , 'name' , 'jack' ) redis.call('set' , 'name' , 'Rose' ) local name = redis.call('get' , 'name' )return name
参数传递 :
KEYS数组:接收key类型参数
ARGV数组:接收其他参数
Redis脚本调用
命令行调用 :
1 2 3 4 5 EVAL "redis.call('set', 'name', 'jack')" 0 EVAL "redis.call('set', KEYS[1], ARGV[1])" 1 name Rose
Java调用 :
1 2 3 4 5 6 7 8 9 DefaultRedisScript<Long> script = new DefaultRedisScript <>(); script.setScriptText("redis.call('set', KEYS[1], ARGV[1])" ); script.setResultType(Long.class); redisTemplate.execute(script, Collections.singletonList("name" ), "Jack" );
原子性保证机制
Redis单线程模型 :Lua脚本执行期间,Redis不会执行其他命令
原子性验证 :
1 2 3 线程A:执行Lua脚本(验证+删除) Redis:脚本执行期间,线程B的请求排队等待 结果:保证验证和删除操作的原子性
性能优势 :
✅ 网络开销少 :一次交互完成多个操作
✅ 原子性保证 :Redis单线程执行
✅ 减少竞态 :避免客户端并发问题
4.5 分布式锁总结
分布式锁演进历程
版本
实现方式
解决问题
存在问题
V1
SETNX + DEL
基本互斥
误删锁、死锁
V2
SET NX EX + 线程标识
死锁避免
误删问题
V3
线程标识 + 验证删除
防误删
原子性问题
V4
Lua脚本原子操作
原子性保证
功能单一
V5
Redisson框架
完整功能
依赖第三方
Redis分布式锁核心特性
基本特性 :
✅ 互斥性 :SETNX保证同一时间只有一个客户端获取锁
✅ 死锁避免 :EXPIRE设置超时时间,防止死锁
✅ 安全性 :线程唯一标识,防止误删
✅ 原子性 :Lua脚本保证操作原子性
高级特性 (Redisson提供):
🔄 可重入性 :同一线程可重复获取锁
⏰ 锁续期 :WatchDog自动续期,防止业务未执行完锁过期
🔄 可重试 :获取锁失败可自动重试
🏗️ 主从一致性 :RedLock算法保证主从一致性
使用建议
简单场景 :
1 2 3 4 5 String lockKey = "lock:business:" + userId;String threadId = UUID.randomUUID().toString();boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, threadId, 30 , TimeUnit.SECONDS);
复杂场景 :
1 2 3 4 RLock lock = redissonClient.getLock("lock:order:" + userId);boolean locked = lock.tryLock(1 , 30 , TimeUnit.SECONDS);
选择原则 :
低频简单操作 :手写Redis分布式锁
高频并发场景 :Redisson框架
强一致性要求 :Zookeeper分布式锁
5. 分布式锁-redission
5.1 Redisson概述
什么是Redisson
Redisson :基于Redis的Java驻内存数据网格(In-Memory Data Grid)
核心功能 :
🔒 分布式锁 :可重入锁、公平锁、联锁、红锁等
📦 分布式对象 :Object、List、Set、Map、Queue等
🔄 分布式服务 :远程服务、消息服务、执行器服务等
优势特点 :
✅ 功能完善 :提供各种分布式锁实现
✅ 可重入性 :支持同一线程重复获取锁
✅ 自动续期 :WatchDog机制自动延长锁有效期
✅ 高可用 :支持主从、哨兵、集群模式
解决手写Redis锁的问题
问题
手写Redis锁
Redisson解决方案
不可重入
同一线程无法重复获取锁
内置可重入机制
不可重试
获取失败只能放弃
支持获取锁超时重试
锁续期
固定过期时间
WatchDog自动续期
主从一致性
主从切换可能丢锁
RedLock算法
5.2 Redisson快速入门
Maven依赖
1 2 3 4 5 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13 .6 </version> </dependency>
Redisson客户端配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient () { Config config = new Config (); config.useSingleServer() .setAddress("redis://192.168.xxx.101:6379" ) .setPassword("123321" ); return Redisson.create(config); } }
分布式锁使用示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Resource private RedissonClient redissonClient;@Test void testRedisson () throws Exception{ RLock lock = redissonClient.getLock("anyLock" ); boolean isLock = lock.tryLock(1 , 10 , TimeUnit.SECONDS); if (isLock){ try { System.out.println("执行业务" ); } finally { lock.unlock(); } } }
秒杀业务集成Redisson锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Service public class VoucherOrderServiceImpl implements IVoucherOrderService { @Resource private RedissonClient redissonClient; @Override public Result seckillVoucher (Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始!" ); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已经结束!" ); } if (voucher.getStock() < 1 ) { return Result.fail("库存不足!" ); } Long userId = UserHolder.getUser().getId(); RLock lock = redissonClient.getLock("lock:order:" + userId); boolean isLock = lock.tryLock(); if (!isLock) { return Result.fail("不允许重复下单" ); } try { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } finally { lock.unlock(); } } }
5.3 Redisson分布式锁详解
5.3.1 Redisson可重入锁原理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @Resource private RedissonClient redissonClient;private RLock lock;@BeforeEach void setUp () { lock = redissonClient.getLock("lock" ); } @Test void method1 () { boolean success = lock.tryLock(); if (!success) { log.error("获取锁失败,1" ); return ; } try { log.info("获取锁成功" ); method2(); } finally { log.info("释放锁,1" ); lock.unlock(); } } void method2 () { RLock lock = redissonClient.getLock("lock" ); boolean success = lock.tryLock(); if (!success) { log.error("获取锁失败,2" ); return ; } try { log.info("获取锁成功,2" ); } finally { log.info("释放锁,2" ); lock.unlock(); } }
同一线程内方法调用时,若method1已持有锁,method2需获取同一把锁,通过判断线程ID实现可重入:state+1获取锁,state-1释放锁,减至0时真正释放
Redis中的锁存储结构
存储格式 :
1 2 3 4 Key: lock_name (锁名称) Value: Hash结构 ├─ field: UUID + ":" + threadId (线程唯一标识) └─ value: 重入次数 (整数)
示例 :
1 2 3 lock:order:12345 ├─ "8f3e2a1c:1" : 2 (线程1重入了2次) └─ "9d4c5b2a:2" : 1 (线程2重入了1次)
可重入锁获取Lua脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 if (redis.call('exists' , KEYS[1 ]) == 0 ) then redis.call('hset' , KEYS[1 ], ARGV[2 ], 1 ); redis.call('pexpire' , KEYS[1 ], ARGV[1 ]); return nil ; end ;if (redis.call('hexists' , KEYS[1 ], ARGV[2 ]) == 1 ) then redis.call('hincrby' , KEYS[1 ], ARGV[2 ], 1 ); redis.call('pexpire' , KEYS[1 ], ARGV[1 ]); return nil ; end ;return redis.call('pttl' , KEYS[1 ]);
脚本逻辑 :
锁不存在 :创建新锁,重入次数设为1
当前线程重入 :重入次数+1,重置过期时间
其他线程持有 :返回锁剩余时间,抢锁失败
可重入锁释放Lua脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 if (redis.call('hexists' , KEYS[1 ], ARGV[1 ]) == 0 ) then return nil ; end ;counter = redis.call('hincrby' , KEYS[1 ], ARGV[1 ], -1 ); if (counter > 0 ) then redis.call('pexpire' , KEYS[1 ], ARGV[2 ]); return 0 ; else redis.call('del' , KEYS[1 ]); return 1 ; end ;
释放逻辑 :
重入次数>0 :仅减少重入次数,不删除锁
重入次数=0 :删除整个锁
锁不存在 :直接返回,防止误删
5.3.2 Redisson锁重试和WatchDog机制
锁获取重试机制
重试流程 :
1 2 boolean isLock = lock.tryLock(1 , 10 , TimeUnit.SECONDS);
内部实现 :
首次尝试 :立即执行Lua脚本抢锁
失败重试 :如果锁被占用,等待锁释放后重试
超时控制 :在指定等待时间内持续重试
返回结果 :成功返回true,超时返回false
看门狗自动续期机制
续期原理 :
续期流程 :
初始有效期 :默认30秒(可配置)
续期触发 :每有效期/3时间触发一次(默认10秒)
续期条件 :业务线程仍在运行且持有锁
续期失败 :线程宕机或锁已释放,停止续期
代码实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 private void renewExpiration () { ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee == null ) { return ; } Timeout task = commandExecutor.getConnectionManager().newTimeout( new TimerTask () { @Override public void run (Timeout timeout) throws Exception { RFuture<Boolean> future = renewExpirationAsync(threadId); future.onComplete((res, e) -> { if (res) { renewExpiration(); } }); } }, internalLockLeaseTime / 3 , TimeUnit.MILLISECONDS ); ee.setTimeout(task); }
不同加锁方式的对比
方法
是否重试
是否续期
适用场景
tryLock()
❌
❌
简单获取,立即返回
tryLock(waitTime, leaseTime, unit)
✅
❌
带超时等待,固定有效期
lock()
✅
✅
长期持有,自动续期
lock(leaseTime, unit)
✅
❌
固定有效期,不续期
最佳实践 :
短时操作 :tryLock(1, 10, TimeUnit.SECONDS)
长时操作 :lock() + WatchDog续期
定时任务 :lock(30, TimeUnit.SECONDS) 手动控制有效期
5.3.3 Redisson MultiLock原理
Redis主从架构中的分布式锁失效风险分析
问题场景 :
主节点写入锁 :客户端在Master节点成功获取锁
主节点宕机 :数据还未同步到Slave节点
主从切换 :Slave升级为新的Master
锁信息丢失 :新Master没有锁信息,其他客户端可重新获取锁
解决方案 :MultiLock机制,在多个独立Redis实例上同时加锁
MultiLock核心原理
加锁规则 :
全部成功 :在所有节点都成功加锁才算成功
超时控制 :总超时时间 = 节点数量 × 1500ms
失败处理 :任意节点失败则整个加锁失败
示例代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient () { Config config = new Config (); config.useSingleServer().setAddress("redis://192.168.137.130:6379" ) .setPassword("root" ); return Redisson.create(config); } @Bean public RedissonClient redissonClient2 () { Config config = new Config (); config.useSingleServer().setAddress("redis://92.168.137.131:6379" ) .setPassword("root" ); return Redisson.create(config); } @Bean public RedissonClient redissonClient3 () { Config config = new Config (); config.useSingleServer().setAddress("redis://92.168.137.132:6379" ) .setPassword("root" ); return Redisson.create(config); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @Resource private RedissonClient redissonClient;@Resource private RedissonClient redissonClient2;@Resource private RedissonClient redissonClient3;private RLock lock;@BeforeEach void setUp () { RLock lock1 = redissonClient.getLock("lock" ); RLock lock2 = redissonClient2.getLock("lock" ); RLock lock3 = redissonClient3.getLock("lock" ); lock = redissonClient.getMultiLock(lock1, lock2, lock3); } @Test void method1 () { boolean success = lock.tryLock(); redissonClient.getMultiLock(); if (!success) { log.error("获取锁失败,1" ); return ; } try { log.info("获取锁成功" ); method2(); } finally { log.info("释放锁,1" ); lock.unlock(); } } void method2 () { RLock lock = redissonClient.getLock("lock" ); boolean success = lock.tryLock(); if (!success) { log.error("获取锁失败,2" ); return ; } try { log.info("获取锁成功,2" ); } finally { log.info("释放锁,2" ); lock.unlock(); } }
MultiLock优缺点分析
特性
优点
缺点
可靠性
避免单点故障,主从切换不丢锁
需要维护多个独立Redis实例
性能
并行加锁,性能可接受
网络开销增加,延迟上升
复杂度
使用简单,API统一
需要额外的Redis实例资源
一致性
基于多数派,一致性强
网络分区时可能出现不可用
适用场景 :
✅ 高可靠性要求 :金融交易、订单处理等关键业务
✅ 主从架构 :Redis主从部署环境
❌ 简单场景 :单机Redis即可满足需求
6. 秒杀优化
6.1 异步秒杀思路
同步秒杀的性能瓶颈
传统流程 :
1 查询优惠券 → 判断库存 → 查询订单 → 一人一单校验 → 扣减库存 → 创建订单
性能问题 :
数据库压力大 :每个请求都要多次访问数据库
串行执行 :所有操作顺序执行,耗时累积
线程阻塞 :数据库IO等待导致线程闲置
并发能力低 :QPS受限于数据库性能
优化思路 :将耗时短的逻辑判断移到Redis,耗时长的下单操作异步化
异步化优化方案
优化流程 :
1 2 3 4 5 6 7 8 graph TD A[用户请求] --> B[Redis校验] B --> C{校验通过?} C -->|是| D[返回成功] C -->|否| E[返回失败] D --> F[消息队列] F --> G[异步下单] G --> H[数据库更新]
核心思想 :
快速响应 :Redis内存操作,毫秒级响应
异步处理 :消息队列削峰填谷,平滑处理
最终一致性 :保证订单最终创建成功
异步化实现难点
技术难点 :
Redis原子操作 :如何保证库存扣减和订单记录的原子性?
一人一单校验 :Redis中如何快速判断用户是否已下单?
订单状态跟踪 :如何告知用户订单处理结果?
消息可靠性 :如何保证消息不丢失,正确处理?
解决方案 :
Lua脚本 :保证Redis操作的原子性
Set集合 :使用Redis Set存储已下单用户ID
订单ID预生成 :提前生成订单ID,用于状态查询
消息队列 :使用Redis Stream或专业消息队列
6.2 Redis完成秒杀资格判断
Redis原子操作实现资格判断
核心思路 :
库存预加载 :优惠券信息保存到Redis
原子校验 :Lua脚本一次性完成库存、一人一单判断
异步下单 :校验通过后发送消息到队列
实现步骤 :
1 Redis预加载 → Lua脚本校验 → 消息队列 → 异步下单
优惠券预加载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @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); stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); }
Lua脚本实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 local voucherId = ARGV[1 ] local userId = ARGV[2 ] local orderId = ARGV[3 ] local stockKey = 'seckill:stock:' .. voucherId local orderKey = 'seckill:order:' .. voucherId 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 ) redis.call('sadd' , orderKey, userId) redis.call('xadd' , 'stream.orders' , '*' , 'userId' , userId, 'voucherId' , voucherId, 'id' , orderId) return 0
业务层调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Override public Result seckillVoucher (Long voucherId) { Long userId = UserHolder.getUser().getId(); long orderId = redisIdWorker.nextId("order" ); Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(), String.valueOf(orderId) ); int r = result.intValue(); if (r != 0 ) { return Result.fail(r == 1 ? "库存不足!" : "不能重复下单!" ); } return Result.ok(orderId); }
使用PostMan发送请求,添加优惠券
请求路径:http://localhost:8080/api/voucher/seckill
请求方式:POST
1 2 3 4 5 6 7 8 9 10 11 12 { "shopId" : 1 , "title" : "9999元代金券" , "subTitle" : "365*24小时可用" , "rules" : "全场通用\\nApex猎杀无需预约" , "payValue" : 1000 , "actualValue" : 999900 , "type" : 1 , "stock" : 100 , "beginTime" : "2022-01-01T00:00:00" , "endTime" : "2022-12-31T23:59:59" }
实现关键点
原子性保证 :
✅ Lua脚本 :所有Redis操作一次性执行
✅ 单线程模型 :Redis单线程保证脚本执行不被打断
❌ 事务 :MULTI/EXEC无法保证库存和订单的原子性
数据一致性 :
库存Key :seckill:stock:{voucherId}
订单Key :seckill:order:{voucherId}
消息队列 :stream.orders
返回值设计 :
6.3 基于阻塞队列实现秒杀优化
阻塞队列异步处理方案
核心思路 :
快速响应 :Lua脚本校验通过后立即返回订单ID
异步处理 :订单信息放入阻塞队列,后台线程慢慢处理
流量削峰 :阻塞队列缓冲瞬时高并发请求
实现架构 :
1 用户请求 → Lua脚本校验 → 内存队列 → 单线程处理 → 数据库操作
线程池配置
1 2 3 4 5 6 7 8 9 private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); @PostConstruct private void init () { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler ()); }
订单处理器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 private class VoucherOrderHandler implements Runnable { @Override public void run () { while (true ) { try { VoucherOrder voucherOrder = orderTasks.take(); handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error("处理订单异常" , e); } } } private void handleVoucherOrder (VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); RLock redisLock = redissonClient.getLock("lock:order:" + userId); boolean isLock = redisLock.lock(); if (!isLock) { log.error("不允许重复下单!" ); return ; } try { proxy.createVoucherOrder(voucherOrder); } finally { redisLock.unlock(); } } @Transactional public void createVoucherOrder (VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); int count = query().eq("user_id" , userId).eq("voucher_id" , voucherOrder.getVoucherId()).count(); if (count > 0 ) { log.error("用户已经购买过了" ); return ; } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherOrder.getVoucherId()).gt("stock" , 0 ) .update(); if (!success) { log.error("库存不足" ); return ; } save(voucherOrder); } }
阻塞队列定义
1 2 3 private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue <>(1024 * 1024 );
业务层修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Override public Result seckillVoucher (Long voucherId) { Long userId = UserHolder.getUser().getId(); long orderId = redisIdWorker.nextId("order" ); Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(), String.valueOf(orderId) ); int r = result.intValue(); if (r != 0 ) { return Result.fail(r == 1 ? "库存不足" : "不能重复下单" ); } VoucherOrder voucherOrder = new VoucherOrder (); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); orderTasks.add(voucherOrder); return Result.ok(orderId); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 package com.hmdp.service.impl;import com.hmdp.dto.Result;import com.hmdp.entity.VoucherOrder;import com.hmdp.mapper.VoucherOrderMapper;import com.hmdp.service.ISeckillVoucherService;import com.hmdp.service.IVoucherOrderService;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.hmdp.utils.RedisIdWorker;import com.hmdp.utils.UserHolder;import lombok.extern.slf4j.Slf4j;import org.redisson.api.RLock;import org.redisson.api.RedissonClient;import org.springframework.aop.framework.AopContext;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import javax.annotation.PostConstruct;import javax.annotation.Resource;import java.util.Collections;import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.BlockingQueue;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;@Service @Slf4j public class VoucherOrderServiceImpl extends ServiceImpl <VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Autowired private ISeckillVoucherService seckillVoucherService; @Autowired private RedisIdWorker redisIdWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; private IVoucherOrderService proxy; private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript (); SECKILL_SCRIPT.setLocation(new ClassPathResource ("seckill.lua" )); SECKILL_SCRIPT.setResultType(Long.class); } private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); @PostConstruct private void init () { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler ()); } private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue <>(1024 * 1024 ); private void handleVoucherOrder (VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); RLock redisLock = redissonClient.getLock("order:" + userId); boolean isLock = redisLock.tryLock(); if (!isLock) { log.error("不允许重复下单!" ); return ; } try { proxy.createVoucherOrder(voucherOrder); } finally { redisLock.unlock(); } } private class VoucherOrderHandler implements Runnable { @Override public void run () { while (true ) { try { VoucherOrder voucherOrder = orderTasks.take(); handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error("订单处理异常" , e); } } } } @Override public Result seckillVoucher (Long voucherId) { Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); if (result.intValue() != 0 ) { return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单" ); } long orderId = redisIdWorker.nextId("order" ); VoucherOrder voucherOrder = new VoucherOrder (); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(UserHolder.getUser().getId()); voucherOrder.setId(orderId); orderTasks.add(voucherOrder); return Result.ok(orderId); } @Transactional public void createVoucherOrder (VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); Long voucherId = voucherOrder.getVoucherId(); synchronized (userId.toString().intern()) { int count = query().eq("voucher_id" , voucherId).eq("user_id" , userId).count(); if (count > 0 ) { log.error("你已经抢过优惠券了哦" ); return ; } boolean success = seckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .gt("stock" , 0 ) .update(); if (!success) { log.error("库存不足" ); } save(voucherOrder); } } }
阻塞队列方案优缺点
优点 :
✅ 实现简单 :JDK原生支持,无需额外依赖
✅ 性能优秀 :内存操作,延迟极低
✅ 削峰填谷 :缓冲瞬时高并发请求
缺点 :
❌ 内存限制 :队列容量有限,数据可能丢失
❌ 单点故障 :JVM宕机导致队列数据丢失
❌ 扩展性差 :无法分布式部署,只能单机处理
适用场景 :
✅ 单机部署 :应用部署在单台服务器
✅ 数据可接受丢失 :秒杀活动数据允许少量丢失
❌ 分布式部署 :需要多机协同处理
❌ 数据强一致性 :订单数据不能丢失
小总结:
秒杀业务的优化思路是什么?
先利用Redis完成库存余量、一人一单判断,完成抢单业务
再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
7. Redis消息队列
7.1.1 消息队列基础
什么是消息队列
定义 :消息队列是一种异步通信机制,用于在不同组件或系统之间传递消息。
三个核心角色 :
消息队列 :存储和管理消息的消息代理(Message Broker)
生产者 :发送消息到消息队列的应用或服务
消费者 :从消息队列获取消息并处理的应用或服务
通信模型 :
消息队列的使用场景
生活例子 :快递柜系统
快递员(生产者) :把快递放入快递柜
快递柜(消息队列) :临时存储快递
用户(消费者) :从快递柜取快递
技术优势 :
解耦 :生产者和消费者不需要直接通信
异步 :生产者不需要等待消费者处理完成
削峰填谷 :缓冲瞬时高并发请求
可靠性 :消息持久化,确保不丢失
秒杀场景应用 :
Redis消息队列方案
实现方式
数据结构
特点
适用场景
List队列
List
简单可靠,支持阻塞
简单消息传递
PubSub
发布订阅
实时推送,不支持持久化
实时通知
Stream
Stream
功能完善,支持持久化
复杂消息系统
选择建议 :
✅ 简单场景 :使用List实现
✅ 实时通知 :使用PubSub
✅ 复杂业务 :使用Stream
❌ 大数据量 :建议使用专业MQ(Kafka、RabbitMQ)
7.1.2 基于List实现消息队列
List结构消息队列原理
基本原理 :Redis的List是双向链表,天然支持队列操作
操作命令 :
1 2 3 4 5 6 7 8 # 生产者:从左侧入队 LPUSH queue_name message # 消费者:从右侧出队 RPOP queue_name # 阻塞式消费(推荐) BRPOP queue_name timeout
队列模型 :
1 生产者 → LPUSH → [message3, message2, message1] → RPOP → 消费者
List消息队列实现
生产者代码 :
1 2 stringRedisTemplate.opsForList().leftPush("queue:order" , message);
消费者代码 :
1 2 3 4 5 6 7 8 String message = stringRedisTemplate.opsForList() .rightPop("queue:order" , 5 , TimeUnit.SECONDS); if (message != null ) { processMessage(message); }
批量处理 :
1 2 3 List<String> messages = stringRedisTemplate.opsForList() .range("queue:order" , 0 , 99 );
List消息队列优缺点
优点 :
✅ 实现简单 :Redis原生List结构,无需额外配置
✅ 持久化支持 :基于Redis持久化,数据安全
✅ 有序性 :消息按入队顺序消费
✅ 内存大 :不受JVM内存限制
缺点 :
❌ 消息丢失 :消费者处理失败时消息已删除
❌ 单消费者 :一条消息只能被一个消费者处理
❌ 无ACK机制 :无法确认消息是否成功处理
❌ 无重试机制 :处理失败的消息无法重新消费
改进方案 :
消息确认 :消费后不立即删除,先放入"处理中"列表
失败重试 :处理失败的消息重新放回队列
多消费者 :使用多个队列实现消费者组
7.1.3 基于PubSub实现消息队列
PubSub发布订阅模型
核心概念 :
频道(Channel) :消息发布的通道
订阅者(Subscriber) :订阅频道的客户端
发布者(Publisher) :向频道发送消息的客户端
基本命令 :
1 2 3 4 5 6 7 8 # 订阅频道 SUBSCRIBE channel1 channel2 # 发布消息 PUBLISH channel1 "hello world" # 模式订阅(通配符) PSUBSCRIBE news.*
消息流 :
1 发布者 → PUBLISH → Channel → 广播 → 所有订阅者
PubSub消息队列实现
消息发布者 :
1 2 stringRedisTemplate.convertAndSend("order.channel" , orderMessage);
消息订阅者 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Component public class OrderMessageSubscriber extends KeyspaceEventMessageListener { @Autowired public OrderMessageSubscriber (RedisMessageListenerContainer listenerContainer) { super (listenerContainer); } @Override protected void doHandleMessage (Message message) { String channel = new String (message.getChannel()); String body = new String (message.getBody()); if ("order.channel" .equals(channel)) { processOrderMessage(body); } } }
配置监听器 :
1 2 3 4 5 6 7 8 9 10 @Bean public RedisMessageListenerContainer container ( RedisConnectionFactory connectionFactory, OrderMessageSubscriber subscriber) { RedisMessageListenerContainer container = new RedisMessageListenerContainer (); container.setConnectionFactory(connectionFactory); container.addMessageListener(subscriber, new ChannelTopic ("order.channel" )); return container; }
PubSub消息队列优缺点
优点 :
✅ 实时性 :消息立即推送给所有订阅者
✅ 多订阅者 :一个消息可被多个消费者同时接收
✅ 简单易用 :Redis原生支持,配置简单
✅ 模式匹配 :支持通配符订阅多个频道
缺点 :
❌ 无持久化 :消息不存储,订阅者离线时消息丢失
❌ 无确认机制 :无法保证消息被成功处理
❌ 无重试机制 :处理失败的消息无法重新投递
❌ 内存压力 :大量订阅者可能导致内存问题
适用场景 :
✅ 实时通知 :系统状态变更、配置更新等
✅ 广播消息 :需要多个消费者同时接收的场景
❌ 重要业务 :订单处理、支付等关键业务
❌ 离线处理 :消费者可能离线的场景
基于PubSub的消息队列有哪些优缺点?
优点:
缺点:
不支持数据持久化
无法避免消息丢失
消息堆积有上限,超出时数据丢失
7.1.4 基于Stream实现消息队列
实现原理
Stream 是 Redis 5.0 引入的新数据类型,专门用于实现消息队列功能:
消息存储 :每个消息都有唯一ID,格式为时间戳-序列号
消费者组 :支持多消费者组同时消费,互不干扰
ACK机制 :消费者处理完消息后需要确认,保证消息至少消费一次
核心命令 :
XADD:发送消息
XREAD:读取消息
XREADGROUP:消费者组读取
XACK:消息确认
Stream相比List和PubSub,提供了更完善的消息队列功能,支持消息持久化、消费者组和ACK机制。
代码示例
生产者发送消息 :
1 2 3 4 5 6 7 8 Map<String, Object> message = new HashMap <>(); message.put("voucherId" , voucherId); message.put("userId" , userId); message.put("orderId" , orderId); String recordId = stringRedisTemplate.opsForStream() .add("stream.orders" , message);
消费者组消费 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 private class VoucherOrderHandler implements Runnable { @Override public void run () { while (true ) { try { 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())); 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 ); createVoucherOrder(voucherOrder); stringRedisTemplate.opsForStream().acknowledge("stream.orders" , "g1" , record.getId()); } catch (Exception e) { log.error("处理订单异常" , e); handlePendingList(); } } } private void handlePendingList () { while (true ) { try { List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream() .read(Consumer.from("g1" , "c1" ), StreamReadOptions.empty().count(1 ), StreamOffset.create("stream.orders" , ReadOffset.from("0" ))); if (list == null || list.isEmpty()) { break ; } MapRecord<String, Object, Object> record = list.get(0 ); Map<Object, Object> value = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder (), true ); createVoucherOrder(voucherOrder); stringRedisTemplate.opsForStream().acknowledge("stream.orders" , "g1" , record.getId()); } catch (Exception e) { log.error("处理pending订单异常" , e); try { Thread.sleep(20 ); } catch (Exception ex) { ex.printStackTrace(); } } } } }
优缺点分析
特性
Stream
说明
消息持久化
✅ 支持
消息保存在内存和磁盘中
消息回溯
✅ 支持
可以读取历史消息
消费者组
✅ 支持
多组消费者独立消费
ACK机制
✅ 支持
保证消息至少消费一次
消息堆积
✅ 支持
内存足够时可堆积大量消息
实现复杂度
⭐⭐⭐ 中等
需要理解消费者组概念
适用场景 :
需要消息持久化的业务场景
多消费者组独立消费
消息处理可靠性要求高的场景
复杂的秒杀订单处理
7.1.5 消息队列对比总结
功能特性对比
功能特性
List结构
PubSub
Stream
消息持久化
✅ 支持
❌ 不支持
✅ 支持
消息回溯
❌ 不支持
❌ 不支持
✅ 支持
消费者组
❌ 不支持
❌ 不支持
✅ 支持
消息确认
❌ 不支持
❌ 不支持
✅ 支持
阻塞读取
✅ 支持
✅ 支持
✅ 支持
消息堆积
受内存限制
受内存限制
受内存限制
性能对比
性能指标
List
PubSub
Stream
吞吐量
高
最高
高
延迟
低
最低
低
CPU消耗
低
最低
中等
内存使用
低
低
中等
选择建议
List队列 :简单的任务队列,对可靠性要求不高
PubSub :实时消息推送,允许消息丢失的场景
Stream :业务消息队列,需要高可靠性和完整功能
8. 达人探店
发布探店笔记
探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:
tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
tb_blog表结构:
Field
Type
Collation
Null
Key
Default
Extra
Comment
id
bigint unsigned
(NULL)
NO
PRI
(NULL)
auto_increment
主键
shop_id
bigint
(NULL)
NO
(NULL)
商户id
user_id
bigint unsigned
(NULL)
NO
(NULL)
用户id
title
varchar(255)
utf8mb4_unicode_ci
NO
(NULL)
标题
images
varchar(2048)
utf8mb4_general_ci
NO
(NULL)
探店的照片,最多9张,多张以","隔开
content
varchar(2048)
utf8mb4_unicode_ci
NO
(NULL)
探店的文字描述
liked
int unsigned
(NULL)
YES
0
点赞数量
comments
int unsigned
(NULL)
YES
(NULL)
评论数量
create_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED
创建时间
update_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED on update CURRENT_TIMESTAMP
更新时间
tb_blog_comments:其他用户对探店笔记的评价
tb_blog_comments表结构:
Field
Type
Collation
Null
Key
Default
Extra
Comment
id
bigint unsigned
(NULL)
NO
PRI
(NULL)
auto_increment
主键
user_id
bigint unsigned
(NULL)
NO
(NULL)
用户id
blog_id
bigint unsigned
(NULL)
NO
(NULL)
探店id
parent_id
bigint unsigned
(NULL)
NO
(NULL)
关联的1级评论id,如果是一级评论,则值为0
answer_id
bigint unsigned
(NULL)
NO
(NULL)
回复的评论id
content
varchar(255)
utf8mb4_general_ci
NO
(NULL)
回复的内容
liked
int unsigned
(NULL)
YES
(NULL)
点赞数
status
tinyint unsigned
(NULL)
YES
(NULL)
状态,0:正常,1:被举报,2:禁止查看
create_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED
创建时间
update_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED on update CURRENT_TIMESTAMP
更新时间
达人探店功能允许用户发布探店笔记,包括图片上传、笔记发布、点赞互动等功能。本章节将介绍如何使用Redis优化这些功能的实现。
对应的实体类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 @Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("tb_blog") public class Blog implements Serializable { private static final long serialVersionUID = 1L ; @TableId(value = "id", type = IdType.AUTO) private Long id; private Long shopId; private Long userId; @TableField(exist = false) private String icon; @TableField(exist = false) private String name; @TableField(exist = false) private Boolean isLike; private String title; private String images; private String content; private Integer liked; private Integer comments; private LocalDateTime createTime; private LocalDateTime updateTime; }
8.1 图片上传与笔记发布
探店笔记发布包含两个核心功能:
图片上传 :支持单张图片上传,生成唯一文件名
笔记发布 :保存探店博文,关联上传的图片
图片上传控制器 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Slf4j @RestController @RequestMapping("upload") public class UploadController { @PostMapping("blog") public Result uploadImage (@RequestParam("file") MultipartFile image) { try { String originalFilename = image.getOriginalFilename(); String fileName = createNewFileName(originalFilename); image.transferTo(new File (SystemConstants.IMAGE_UPLOAD_DIR, fileName)); log.debug("文件上传成功,{}" , fileName); return Result.ok(fileName); } catch (IOException e) { throw new RuntimeException ("文件上传失败" , e); } } }
笔记发布控制器 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RestController @RequestMapping("/blog") public class BlogController { @Resource private IBlogService blogService; @PostMapping public Result saveBlog (@RequestBody Blog blog) { UserDTO user = UserHolder.getUser(); blog.setUpdateTime(user.getId()); blogService.saveBlog(blog); return Result.ok(blog.getId()); } }
注意:需要修改SystemConstants.IMAGE_UPLOAD_DIR为实际的图片存储路径,生产环境中建议使用云存储服务。
文件命名策略 :使用UUID确保文件名唯一性,避免冲突
异常处理 :捕获IO异常并转换为业务异常
用户认证 :通过UserHolder获取当前登录用户信息
返回值设计 :返回生成的文件名,供前端展示和后续业务使用
8.2 查看探店笔记
查看探店笔记功能需要:
笔记查询 :根据ID查询笔记详情
用户信息 :查询笔记作者信息
点赞状态 :判断当前用户是否点赞
controller
1 2 3 4 5 6 7 8 9 10 @GetMapping("/hot") public Result queryHotBlog (@RequestParam(value = "current", defaultValue = "1") Integer current) { return blogService.queryHotBlog(current); } @GetMapping("/{id}") public Result queryById (@PathVariable Integer id) { return blogService.queryById(id); }
service
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @Override public Result queryHotBlog (Integer current) { Page<Blog> page = query() .orderByDesc("liked" ) .page(new Page <>(current, SystemConstants.MAX_PAGE_SIZE)); List<Blog> records = page.getRecords(); records.forEach(this ::queryBlogUser); return Result.ok(records); } @Override public Result queryById (Integer id) { Blog blog = getById(id); if (blog == null ) { return Result.fail("评价不存在或已被删除" ); } queryBlogUser(blog); return Result.ok(blog); } private void queryBlogUser (Blog blog) { Long userId = blog.getUserId(); User user = userService.getById(userId); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); }
数据完整性 :先查询笔记,再查询关联的用户信息
异常处理 :笔记不存在时给出友好提示
数据组装 :queryBlogUser方法负责填充用户相关信息
8.3 点赞功能优化
初始点赞实现存在严重问题:
1 2 3 4 5 6 @GetMapping("/likes/{id}") public Result queryBlogLikes (@PathVariable("id") Long id) { blogService.update().setSql("liked = liked +1 " ).eq("id" ,id).update(); return Result.ok(); }
问题 :用户可以无限点赞,没有任何限制机制
同一个用户只能点赞一次,再次点击则取消点赞
如果当前用户已经点赞,则点赞按钮高亮显示
使用Redis的Set集合记录点赞用户,保证唯一性
1. 修改Blog实体类 :
1 2 @TableField(exist = false) private Boolean isLike;
2. 点赞业务逻辑 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @PutMapping("/like/{id}") public Result likeBlog (@PathVariable("id") Long id) { return blogService.likeBlog(id); } @Override public Result likeBlog (Long id) { Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + id; Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); if (BooleanUtil.isFalse(isMember)){ boolean isSuccess = update().setSql("liked = liked + 1" ).eq("id" , id).update(); if (isSuccess){ stringRedisTemplate.opsForSet().add(key,userId.toString()); } }else { boolean isSuccess = update().setSql("liked = liked - 1" ).eq("id" , id).update(); if (isSuccess){ stringRedisTemplate.opsForSet().remove(key,userId.toString()); } } return Result.ok(); }
关键点分析
Redis Key设计 :blog:liked:{blogId},每个笔记一个独立的Set
原子性保证 :先判断再操作,通过Redis Set保证用户唯一性
数据一致性 :数据库和Redis同步更新,确保点赞数准确
性能优化 :Redis Set操作O(1)时间复杂度,性能优异
点击点赞按钮,查看发送的请求
1 2 请求网址: http://localhost:8080/api/blog/like/4 请求方法: PUT
8.4 点赞排行榜
在探店笔记详情页面需要展示点赞排行榜,显示最早点赞的TOP5用户:
排序需求 :按点赞时间排序,最早点赞的排在前面
唯一性 :每个用户只能出现一次
性能要求 :快速查询TOP5用户
使用Redis的SortedSet(ZSet)数据结构:
唯一性 :ZSet保证成员唯一
排序能力 :根据score值排序,score使用时间戳
范围查询 :支持按排名范围查询
1. 修改点赞逻辑(使用ZSet) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Override public Result likeBlog (Long id) { Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + id; Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); if (score == null ) { boolean isSuccess = update().setSql("liked = liked + 1" ).eq("id" , id).update(); if (isSuccess) { stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis()); } } else { boolean isSuccess = update().setSql("liked = liked - 1" ).eq("id" , id).update(); if (isSuccess) { stringRedisTemplate.opsForZSet().remove(key, userId.toString()); } } return Result.ok(); }
2. 查询点赞排行榜 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Override public Result queryBlogLikes (Long id) { String key = BLOG_LIKED_KEY + id; Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0 , 4 ); if (top5 == null || top5.isEmpty()) { return Result.ok(Collections.emptyList()); } List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList()); String idStr = StrUtil.join("," , ids); List<UserDTO> userDTOS = userService.query() .in("id" , ids).last("ORDER BY FIELD(id," + idStr + ")" ).list() .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(userDTOS); }
关键点分析
Score设计 :使用时间戳作为score,自然按时间排序
范围查询 :zrange key 0 4获取前5个用户,时间复杂度O(log(n)+m)
数据一致性 :点赞和取消点赞时同步更新数据库和Redis
内存优化 :只存储用户ID,不存储完整用户信息
9. 好友关注
9.1 关注和取消关注
针对用户的操作:可以对用户进行关注和取消关注功能。
好友关注功能实现用户之间的关注关系管理:
关注用户 :用户A关注用户B
取消关注 :用户A取消对用户B的关注
关注状态查询 :查询用户A是否关注了用户B
1. 数据库表结构 :
1 2 3 4 5 6 7 8 CREATE TABLE `tb_follow` ( `id` bigint (0 ) NOT NULL AUTO_INCREMENT, `user_id` bigint (20 ) NOT NULL COMMENT '用户id' , `follow_user_id` bigint (20 ) NOT NULL COMMENT '关联的用户id' , `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' , PRIMARY KEY (`id`), UNIQUE INDEX `idx_user_id_follow_user_id`(`user_id`, `follow_user_id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户关注表' ;
2. 控制器层 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @RestController @RequestMapping("/follow") public class FollowController { @Resource private IFollowService followService; @PutMapping("/{id}/{isFollow}") public Result follow (@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) { return followService.follow(followUserId, isFollow); } @GetMapping("/or/not/{id}") public Result isFollow (@PathVariable("id") Long followUserId) { return followService.isFollow(followUserId); } }
3. 业务逻辑层 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public Result isFollow (Long followUserId) { Long userId = UserHolder.getUser().getId(); Integer count = query().eq("user_id" , userId).eq("follow_user_id" , followUserId).count(); return Result.ok(count > 0 ); } @Override public Result follow (Long followUserId, Boolean isFollow) { Long userId = UserHolder.getUser().getId(); if (isFollow) { Follow follow = new Follow (); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess = save(follow); } else { remove(new QueryWrapper <Follow>() .eq("user_id" , userId).eq("follow_user_id" , followUserId)); } return Result.ok(); }
关键点分析
唯一索引 :idx_user_id_follow_user_id防止重复关注
事务性 :关注操作同时操作数据库,保证数据一致性
幂等性 :重复关注或取消关注不会产生副作用
性能优化 :关注状态查询使用count,比查询全表更高效
9.2 共同关注
在博主个人页面展示当前用户与博主的共同关注:
场景 :用户A访问用户B的个人主页
需求 :显示A和B都关注的用户列表
技术选型 :Redis Set集合的交集操作
使用Redis Set数据结构存储关注关系:
Key设计 :follows:{userId} 存储用户关注的所有人
集合操作 :SINTER命令求两个集合的交集
性能优势 :Set操作时间复杂度O(N),比数据库查询更高效
1. 改造关注逻辑(添加Redis缓存) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Override public Result follow (Long followUserId, Boolean isFollow) { Long userId = UserHolder.getUser().getId(); String key = "follows:" + userId; if (isFollow) { Follow follow = new Follow (); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess = save(follow); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, followUserId.toString()); } } else { boolean isSuccess = remove(new QueryWrapper <Follow>() .eq("user_id" , userId).eq("follow_user_id" , followUserId)); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, followUserId.toString()); } } return Result.ok(); }
2. 共同关注查询 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override public Result followCommons (Long targetUserId) { Long userId = UserHolder.getUser().getId(); String key = "follows:" + userId; String key2 = "follows:" + targetUserId; Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2); if (intersect == null || intersect.isEmpty()) { return Result.ok(Collections.emptyList()); } List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList()); List<UserDTO> users = userService.listByIds(ids) .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(users); }
关键点分析
数据一致性 :数据库和Redis双写一致性,关注成功才写入Redis
内存优化 :只存储用户ID,不存储完整用户信息
异常处理 :交集为空时返回空列表,避免空指针异常
性能考虑 :使用listByIds批量查询,减少数据库访问次数
9.3 Feed流实现方案
当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。
Feed流的实现有两种模式:
Feed流产品有两种常见模式:
Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
优点:信息全面,不会有缺失。并且实现也相对简单
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
缺点:如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
我们本次针对好友的操作,采用的就是Timeline的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可
,因此采用Timeline的模式。该模式的实现方案有三种:
拉模式 :也叫做读扩散
该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
推模式 :也叫做写扩散。
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
推拉结合模式 :也叫做读写混合,兼具推和拉两种模式的优点。
发件人端策略:
普通用户:采用"推"模式,直接将消息写入所有粉丝的收件箱(粉丝少,压力小)
大V用户:采用"推拉结合"模式,消息先写入自己的发件箱,然后只推送给活跃粉丝
收件人端策略:
活跃粉丝:无论是大V还是普通用户的消息,都直接推送到收件箱
普通粉丝:只在上线时从发件箱中拉取未读消息
核心优势:
平衡了系统性能和用户体验
避免了给所有粉丝推送造成的巨大压力
保证了活跃用户的实时性体验
降低了存储和计算成本
Feed流(关注推送)为用户提供沉浸式内容消费体验:
Timeline模式 :按时间排序,信息全面但噪音较多
智能排序 :算法推荐,用户粘度高但实现复杂
本例选择 :基于好友关系的Timeline模式
模式
原理
优点
缺点
适用场景
拉模式
读取时从关注列表拉取内容
节省存储空间
延迟高,压力大
小用户量
推模式
发布时推送到粉丝收件箱
时效性好
内存消耗大
普通用户
推拉结合
普通用户推,大V推拉结合
平衡性能
实现复杂
大用户量
实现选择
本例采用推模式 实现:
用户发布笔记时,主动推送到所有粉丝的收件箱
使用Redis SortedSet存储,score为时间戳
支持按时间排序和分页查询
关键点分析
数据结构 :ZSet<blogId, timestamp> 天然按时间排序
写入策略 :发布时同步写入所有粉丝收件箱
读取优化 :支持范围查询和滚动分页
存储优化 :只存储blogId,不存储完整内容
推模式适合好友关系场景,时效性好,实现简单。对于大V用户,可后续升级为推拉结合模式。
9.4 推送到粉丝收件箱
实现笔记发布时的粉丝推送功能:
触发时机 :用户发布探店笔记时
推送对象 :笔记作者的所有粉丝
存储要求 :按时间戳排序,支持分页查询
使用Redis SortedSet作为粉丝收件箱:
Key格式 :feed:{userId} 存储用户的收件箱
Value格式 :ZSet<blogId, timestamp>
推送逻辑 :遍历所有粉丝,写入对应收件箱
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @Override public Result saveBlog (Blog blog) { UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); boolean isSuccess = save(blog); if (!isSuccess){ return Result.fail("新增笔记失败!" ); } List<Follow> follows = followService.query() .eq("follow_user_id" , user.getId()).list(); for (Follow follow : follows) { Long userId = follow.getUserId(); String key = FEED_KEY + userId; stringRedisTemplate.opsForZSet() .add(key, blog.getId().toString(), System.currentTimeMillis()); } return Result.ok(blog.getId()); }
关键点分析
原子性 :数据库保存成功后才进行推送
批量操作 :遍历所有粉丝,逐个写入收件箱
时间戳 :使用当前时间作为score,保证时间排序
异常处理 :任一粉丝推送失败不影响其他粉丝
推模式的关键是数据一致性,确保数据库保存成功后才进行Redis推送。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override public Result saveBlog (Blog blog) { UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); boolean isSuccess = save(blog); if (!isSuccess){ return Result.fail("新增笔记失败!" ); } List<Follow> follows = followService.query().eq("follow_user_id" , user.getId()).list(); for (Follow follow : follows) { Long userId = follow.getUserId(); String key = FEED_KEY + userId; stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis()); } return Result.ok(blog.getId()); }
9.5 实现分页查询收件箱
需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息:
Feed流分页查询面临特殊挑战:
数据动态性 :新数据不断插入,传统分页会重复读取
滚动分页 :基于时间戳和偏移量实现无重复分页
性能要求 :需要高效的范围查询和排序
1 传统分页
传统了分页在feed流是不适用的,因为我们的数据会随时发生变化
假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10~6 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做。
2 Feed流的滚动分页
我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据
举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了
使用Redis SortedSet的滚动分页:
查询命令 :ZREVRANGEBYSCORE key Max Min LIMIT offset count
分页参数 :max(最大时间戳)、offset(偏移量)、count(每页数量)
返回数据 :blogId列表、最小时间戳、新偏移量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 @GetMapping("/of/follow") public Result queryBlogOfFollow ( @RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) { return blogService.queryBlogOfFollow(max, offset); } @Override public Result queryBlogOfFollow (Long max, Integer offset) { Long userId = UserHolder.getUser().getId(); String key = FEED_KEY + userId; Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0 , max, offset, 2 ); if (typedTuples == null || typedTuples.isEmpty()) { return Result.ok(); } List<Long> ids = new ArrayList <>(typedTuples.size()); long minTime = 0 ; int os = 1 ; for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { ids.add(Long.valueOf(tuple.getValue())); long time = tuple.getScore().longValue(); if (time == minTime){ os++; }else { minTime = time; os = 1 ; } } String idStr = StrUtil.join("," , ids); List<Blog> blogs = query().in("id" , ids).last("ORDER BY FIELD(id," + idStr + ")" ).list(); for (Blog blog : blogs) { queryBlogUser(blog); isBlogLiked(blog); } ScrollResult r = new ScrollResult (); r.setList(blogs); r.setOffset(os); r.setMinTime(minTime); return Result.ok(r); }
关键点分析
滚动分页 :基于时间戳和偏移量,避免重复数据
ZSet查询 :reverseRangeByScoreWithScores 支持范围查询
偏移量计算 :处理相同时间戳的多条数据
数据完整性 :查询blog详情和点赞状态
滚动分页的核心是记录上次查询的最小时间戳和偏移量,下次查询从该位置继续。
10. 附近商户
10.1 GEO数据结构基本用法
功能概述 :存储和查询地理坐标信息
Redis GEO(地理坐标)支持存储和查询地理坐标信息:
坐标存储 :经度(longitude)、纬度(latitude)、成员(member)
距离计算 :两点间的球面距离 单位 m-米,km-千米,mi-英⾥,ft-英尺
范围查询 :圆形或矩形范围内的成员
排序返回 :按距离排序查询结果
常用命令
命令
说明
示例
GEOADD
添加地理坐标
GEOADD cities 116.405 39.905 beijing
GEODIST
计算两点距离
GEODIST cities beijing shanghai km
GEOPOS
获取成员坐标
GEOPOS cities beijing
GEOSEARCH
范围搜索排序
GEOSEARCH cities FROMLONLAT 116 39 BYRADIUS 10 km
应用场景
附近商户搜索 :根据用户当前位置,查询距离最近的商户
地理位置服务 :提供基于位置的服务,如地图导航、位置提醒
距离计算和排序 :计算两点之间的距离,支持按距离排序查询结果
基于位置的内容推荐 :根据用户位置推荐相关内容,如本地美食、景点等
关键点分析
坐标精度 :支持小数点后6位,约10cm精度
距离单位 :支持m、km、mi、ft等单位
范围查询 :支持圆形和矩形两种查询方式
性能优化 :使用geohash编码,查询效率高
Redis 6.2+ 推荐使用 GEOSEARCH 命令,功能更强大,支持更复杂的查询条件。
10.2 导入店铺数据到GEO
需求分析
将数据库中的店铺信息导入Redis GEO,支持按类型分类存储:
数据分组 :按店铺类型分组,同类店铺存储在同一key下
坐标存储 :使用店铺ID作为member,经纬度作为坐标
批量导入 :提高导入效率,减少网络开销
技术方案
使用Redis GEOADD命令批量导入:
Key格式 :shop:geo:{typeId} 按店铺类型分组
Value格式 :GeoLocation<shopId, Point(x,y)>
批量操作 :opsForGeo().add(key, locations) 一次性导入
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Test void loadShopData () { List<Shop> list = shopService.list(); Map<Long, List<Shop>> map = list.stream() .collect(Collectors.groupingBy(Shop::getTypeId)); for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) { Long typeId = entry.getKey(); String key = SHOP_GEO_KEY + typeId; List<Shop> value = entry.getValue(); List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList <>(value.size()); for (Shop shop : value) { locations.add(new RedisGeoCommands .GeoLocation<>( shop.getId().toString(), new Point (shop.getX(), shop.getY()) )); } stringRedisTemplate.opsForGeo().add(key, locations); } }
关键点分析
数据分组 :按typeId分组,支持按类型筛选
批量导入 :减少网络请求,提高导入效率
内存优化 :只存储shopId,不存储完整信息
坐标格式 :使用RedisGeoCommands.GeoLocation封装
10.3 实现附近商户功能
需求分析
实现基于地理位置的商户查询功能:
坐标查询 :根据用户当前位置查询附近商户
距离排序 :按距离从近到远排序
分页支持 :支持分页查询,避免一次性返回大量数据
类型筛选 :可按商户类型筛选结果
技术方案
使用Redis GEOSEARCH命令实现:
查询命令 :GEOSEARCH key BYLONLAT x y BYRADIUS radius WITHDISTANCE
分页处理 :先查询足够数据,再进行内存分页
距离计算 :自动计算并返回每个商户的距离
引入依赖
SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的pom.xml文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <artifactId>spring-data-redis</artifactId> <groupId>org.springframework.data</groupId> </exclusion> <exclusion> <artifactId>lettuce-core</artifactId> <groupId>io.lettuce</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-redis</artifactId> <version>2.6 .2 </version> </dependency> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.1 .6 .RELEASE</version> </dependency>
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 @GetMapping("/of/type") public Result queryShopByType ( @RequestParam("typeId") Integer typeId, @RequestParam(value = "current", defaultValue = "1") Integer current, @RequestParam(value = "x", required = false) Double x, @RequestParam(value = "y", required = false) Double y ) { return shopService.queryShopByType(typeId, current, x, y); } @Override public Result queryShopByType (Integer typeId, Integer current, Double x, Double y) { if (x == null || y == null ) { Page<Shop> page = query() .eq("type_id" , typeId) .page(new Page <>(current, SystemConstants.DEFAULT_PAGE_SIZE)); return Result.ok(page.getRecords()); } int from = (current - 1 ) * SystemConstants.DEFAULT_PAGE_SIZE; int end = current * SystemConstants.DEFAULT_PAGE_SIZE; String key = SHOP_GEO_KEY + typeId; GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() .search( key, GeoReference.fromCoordinate(x, y), new Distance (5000 ), RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs() .includeDistance() .limit(end) ); if (results == null ) { return Result.ok(Collections.emptyList()); } List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent(); if (list.size() <= from) { return Result.ok(Collections.emptyList()); } List<Long> ids = new ArrayList <>(list.size()); Map<String, Distance> distanceMap = new HashMap <>(list.size()); list.stream().skip(from).forEach(result -> { String shopIdStr = result.getContent().getName(); ids.add(Long.valueOf(shopIdStr)); Distance distance = result.getDistance(); distanceMap.put(shopIdStr, distance); }); String idStr = StrUtil.join("," , ids); List<Shop> shops = query().in("id" , ids).last("ORDER BY FIELD(id," + idStr + ")" ).list(); for (Shop shop : shops) { shop.setDistance(distanceMap.get(shop.getId().toString()).getValue()); } return Result.ok(shops); }
关键点分析
坐标判断 :无坐标时回退到数据库查询
分页计算 :内存分页,避免数据库分页的排序问题
范围查询 :5km范围内搜索,避免数据量过大
距离设置 :自动将距离信息设置到店铺对象中
11. 用户签到
11.1 BitMap功能演示
功能概述
BitMap是Redis中基于String类型实现的位图数据结构:
存储原理 :每个bit位代表一个状态,0表示未签到,1表示已签到
空间优势 :相比MySQL存储,内存占用减少99%以上
性能优势 :位操作时间复杂度为O(1),查询效率极高
常用命令
命令
说明
示例
SETBIT
设置指定位置的bit值
SETBIT sign:1:202401 15 1
GETBIT
获取指定位置的bit值
GETBIT sign:1:202401 15
BITCOUNT
统计1的个数
BITCOUNT sign:1:202401
BITFIELD
批量操作bit位
BITFIELD sign:1:202401 GET u31 0
应用场景
用户签到 :按月存储用户签到状态
活跃用户统计 :统计某时间段活跃用户
特征标记 :标记用户是否具有某种特征
布隆过滤器 :快速判断元素是否存在
关键点分析
存储上限 :最大支持512MB,约43亿个bit位
时间效率 :位操作都是O(1)时间复杂度
空间效率 :1亿用户10次签到仅需12MB内存
数据格式 :按年+月分组存储,便于统计
BitMap适合存储大量布尔值状态,内存占用极小,查询效率极高。
11.2 实现签到功能
需求分析
实现用户签到功能,将当天签到信息保存到Redis中:
签到记录 :记录用户每天的签到状态
状态存储 :使用BitMap存储,0表示未签到,1表示已签到
时间维度 :按月维度存储,便于统计和查询
幂等性 :同一天多次签到只记录一次
技术方案
使用Redis BitMap实现签到存储:
Key格式 :sign:userId:yyyyMM
Bit位置 :用月份中的第几天作为offset(0-30)
命令选择 :使用SETBIT命令设置签到状态
时间获取 :通过后台代码获取当前日期
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @PostMapping("/sign") public Result sign () { return userService.sign(); } @Override public Result sign () { Long userId = UserHolder.getUser().getId(); LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM" )); String key = USER_SIGN_KEY + userId + keySuffix; int dayOfMonth = now.getDayOfMonth(); stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1 , true ); return Result.ok(); }
关键点分析
Key设计 :按用户和月份维度存储,便于查询和统计
Offset计算 :使用月份中的第几天减1作为bit位偏移
幂等性保证 :SETBIT命令天然幂等,重复签到无影响
时间处理 :通过LocalDateTime获取准确的日期信息
BitMap的SETBIT命令天然幂等,同一天多次签到不会产生重复记录。
11.3 签到统计
需求分析
统计用户本月的连续签到天数:
连续签到定义 :从最后一次签到开始向前统计,直到遇到第一次未签到为止
统计范围 :本月第一天到当前日期
返回结果 :连续签到天数
算法思路 :从后向前遍历bit位,遇到0停止
技术方案
使用BITFIELD命令获取本月签到数据:
命令格式 :BITFIELD key GET u[dayOfMonth] 0
数据处理 :获取本月所有签到记录,返回十进制数字
位运算 :通过与1进行与运算,逐个检查bit位
遍历逻辑 :从后向前遍历,遇到0停止计数
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @GetMapping("/sign/count") public Result signCount () { return userService.signCount(); } @Override public Result signCount () { Long userId = UserHolder.getUser().getId(); LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM" )); String key = USER_SIGN_KEY + userId + keySuffix; int dayOfMonth = now.getDayOfMonth(); List<Long> result = stringRedisTemplate.opsForValue().bitField( key, BitFieldSubCommands.create() .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0 ) ); if (result == null || result.isEmpty()) { return Result.ok(0 ); } Long num = result.get(0 ); if (num == null || num == 0 ) { return Result.ok(0 ); } int count = 0 ; while (true ) { if ((num & 1 ) == 0 ) { break ; } else { count++; } num >>>= 1 ; } return Result.ok(count); }
关键点分析
连续签到定义 :从最后一次签到向前统计,遇到0停止
BITFIELD使用 :一次性获取本月所有签到数据
位运算技巧 :通过与1进行与运算判断最低位是否为1
遍历方向 :从后向前遍历,符合连续签到定义
使用无符号右移(>>>")确保高位补0,避免负数影响统计结果。
11.4 BitMap解决缓存穿透
需求分析
使用BitMap解决缓存穿透问题:
缓存穿透 :查询不存在的数据,绕过缓存直达数据库
传统方案 :使用list存储所有有效id,内存占用大
优化方案 :使用BitMap作为布隆过滤器,快速判断id是否存在
误差控制 :通过哈希算法降低冲突概率
技术方案
基于BitMap实现布隆过滤器:
哈希算法 :id % bitmap_size,确定bit位置
存储方式 :将数据库中所有有效id对应的bit位置为1
查询逻辑 :用户查询时,用相同算法计算bit位,为0则一定不存在
误差处理 :哈希冲突可能导致误判,但概率可控
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public void initBloomFilter () { List<Long> validIds = getAllValidIdsFromDB(); int bitmapSize = calculateOptimalSize(validIds.size(), 0.01 ); for (Long id : validIds) { int bitIndex = (int ) (id % bitmapSize); stringRedisTemplate.opsForValue().setBit("bloom:filter" , bitIndex, true ); } } public boolean mightExist (Long id) { int bitmapSize = getBitmapSize(); int bitIndex = (int ) (id % bitmapSize); return stringRedisTemplate.opsForValue().getBit("bloom:filter" , bitIndex); }
关键点分析
内存优化 :相比存储完整id列表,内存占用减少99%以上
查询效率 :位操作时间复杂度O(1),查询极快
误差控制 :通过合理设置bitmap大小,可将误差率控制在1%以内
数据更新 :数据库新增/删除数据时需要同步更新bitmap
12. UV统计
12.1 HyperLogLog原理
概念定义
UV(Unique Visitor) :独立访客量,同一用户多次访问只计1次
PV(Page View) :页面访问量,每次访问都计数
统计挑战 :UV需要去重,传统Set存储方式内存消耗巨大
HyperLogLog原理
HyperLogLog是基于概率算法的基数统计方案:
内存占用 :单个HLL结构小于16KB,与数据量无关
误差范围 :标准误差小于0.81%,对UV统计可接受
底层实现 :基于String结构,使用位运算和哈希算法
适用场景 :大数据量去重统计,如UV、DAU等
Redis命令
命令
说明
示例
PFADD
添加元素
PFADD uv:202401 user1 user2
PFCOUNT
统计基数
PFCOUNT uv:202401
PFMERGE
合并多个HLL
PFMERGE uv:total uv:202401 uv:202402
应用场景
网站UV统计 :统计每日/月独立访客
APP日活统计 :统计每日活跃用户
广告点击统计 :统计独立点击用户数
用户行为分析 :统计参与特定活动的用户数
关键点分析
内存优势 :相比Set存储,内存节省99%以上
误差可控 :0.81%误差对大多数业务场景可接受
合并支持 :支持多时间段数据合并统计
性能高效 :添加和查询操作都是O(1)复杂度
12.2 百万级数据统计测试
测试目标
验证HyperLogLog在百万级数据下的性能表现:
内存占用 :统计100万条数据的内存消耗
统计精度 :误差是否在0.81%范围内
性能表现 :添加和查询操作的耗时
对比测试 :与Set结构进行内存和性能对比
测试方案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Test public void testHyperLogLog () { String key = "test:uv:" + System.currentTimeMillis(); int total = 1_000_000 ; for (int i = 0 ; i < total; i++) { stringRedisTemplate.opsForHyperLogLog().add(key, "user" + i); } Long count = stringRedisTemplate.opsForHyperLogLog().size(key); System.out.println("实际数据量:" + total); System.out.println("统计结果:" + count); System.out.println("误差率:" + Math.abs(count - total) * 100.0 / total + "%" ); Long memory = getMemoryUsage(key); System.out.println("内存占用:" + memory + " bytes" ); }
测试结果
经过测试发现:
内存占用 :约12KB,远低于Set结构的几十MB
统计精度 :误差在0.5%左右,优于官方标称的0.81%
性能表现 :100万条数据添加耗时约2秒
空间效率 :相比Set结构,内存节省99%以上
性能对比
1 2 3 4 | 数据结构 | 内存占用 | 统计精度 | 适用场景 | |---------|----------|----------|----------| | Set | 几十MB | 100% | 小数据量精确统计 | | HyperLogLog | 12KB | 99.2% | 大数据量近似统计 |
关键点分析
内存效率 :百万级数据仅需12KB内存,极其高效
统计精度 :实际误差通常小于理论值,表现优秀
性能稳定 :数据量增加不会显著影响性能
生产适用 :完全满足生产环境的UV统计需求