ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

黑马点评Redis实战:高并发场景下的五类核心应用模式

黑马点评Redis实战:高并发场景下的五类核心应用模式 简介本资源是面向Java后端开发者与Redis初学者的实战型学习项目聚焦高并发场景下的缓存设计与性能优化以虚构的“黑马点评”在线平台为业务背景系统演示Redis在用户会话、热点数据缓存、点赞评论管理、实时消息推送及原子性操作等核心环节的落地应用。压缩包共79个文件含72个Java业务与配置类覆盖Controller、Service、Mapper及RedisTemplate/Lettuce集成代码、2个XML配置文件、2个Lua脚本用于原子化点赞/库存扣减、1个SQL建表语句、1个YAML配置及1个.gitignore整体仅90KB轻量易读目录结构清晰便于按模块理解Redis与Spring Boot的深度整合。目前已有311人学习下载提供完整可运行源码包含缓存穿透/雪崩防护策略、RDBAOF持久化配置示例及基础集群部署说明助开发者快速掌握Redis在真实项目中的工程化实践路径。1. 黑马点评项目为什么非得用 Redis不是“加个缓存”就完事而是把高并发读写、库存扣减、点赞计数、分布式锁全压在一个数据结构引擎上跑通你打开「黑马点评」这个典型 O2O 本地生活类项目源码第一眼看到的不是 Spring Boot 启动类也不是 MySQL 的建表 SQL而是RedisTemplate的泛型配置、Cacheable注解里嵌套的keyGenerator、还有RedissonClient调用getLock(seckill:1001)的那一行——这说明Redis 在这个项目里不是可选插件而是业务主干的承重墙。它同时扛着五类压力首页商户列表的热点缓存穿透防护、用户登录态的 session 共享、团购秒杀的库存原子扣减、笔记点赞数的实时累加每秒数百次 incr、以及分布式环境下唯一订单号生成器的防重校验。网上流传的「黑马点评源码.zip」之所以被反复下载GitHub 上同名仓库 star 数超 3800正是因为它的 Redis 实战路径足够“脏”没用 Spring Cache 封装层遮掩底层细节所有setex、eval、hincrby命令都裸写在 Service 层没回避 Lua 脚本的调试黑匣子连redis.call(exists, KEYS[1]) 0这种判断都贴在代码注释里更关键的是它把 Redis 从「缓存」正名为「状态协调中心」——比如用ZSET存储附近商户的地理距离排序用Stream模拟异步通知用户评价发布成功这些都不是教科书里的标准用法而是真实流量打出来的妥协方案。如果你正在做类似本地生活、社区团购、内容聚合类系统且卡在「QPS 上不去」「缓存雪崩后数据库直接夯死」「秒杀超卖查不出原因」那这份源码不是学习材料是救命手册。它不教你 Redis 命令怎么背而是告诉你当 2000 人同时点「收藏」按钮时HINCRBY user_fav_count 1001 1和EVAL if redis.call(hexists, KEYS[1], ARGV[1]) 1 then return 0 else redis.call(hincrby, KEYS[1], ARGV[1], 1) return 1 end 1 user_fav_count 1001的区别就是线上服务是否能多撑 3 秒。2. 从源码 zip 解压到本地运行三步还原 Redis 在黑马点评中的真实角色2.1 解压后先看清楚这不是一个「Spring Boot Redis」的 Hello World而是一套带完整业务闭环的 Redis 集成方案拿到黑马点评项目实战源码.zip后别急着mvn clean install。先解压用 IDE 打开重点盯三个目录src/main/java/com/hm/redis/这里没有RedisConfig.java取而代之的是RedisConstants.java定义所有 key 前缀SHOP_KEY,BLOG_LIKED_KEY,SECKILL_STOCK_KEY和RedisUtils.java封装了stringOps.set(key, value, timeout, TimeUnit.SECONDS)的 try-catch 重试逻辑且重试间隔是100ms * retryCount不是固定值src/main/resources/redis-config.yml注意它没配spring.redis.host而是写了redis.master.url: redis://127.0.0.1:6379/0和redis.slave.url: redis://127.0.0.1:6380/0—— 这意味着源码默认走Redis 主从读写分离且Readonly注解会路由到 slavesql/目录下的hm_schema.sql和hm_data.sql建表语句里tb_shop表有update_time字段但源码中所有「更新店铺信息」操作都会同步执行redisTemplate.delete(shop: id)强制让缓存失效而非更新——这是为避免「数据库改了但缓存没改」的一致性风险代价是下次请求必然穿透。提示源码里所有 Redis 操作都包裹在try-catch(RedisConnectionFailureException e)中并调用log.warn(Redis connection failed, fallback to DB, e)。这不是容错装饰而是生产环境必须的兜底策略——当 Redis 宕机时业务不能直接报 500而要降级查库。2.2 启动前必改的三个配置项绕过默认陷阱否则连首页都刷不出来黑马点评源码默认使用Lettuce 客户端非 Jedis且启用了连接池和响应超时控制。但开箱即用会翻车必须手动调整# src/main/resources/application.yml spring: redis: # 1. 必须关闭 Lettuce 默认的 auto-reconnect它会在断连后疯狂重试拖垮线程池 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5 shutdown-timeout: 100ms # 关键默认是 100ms太短设为 100ms 已是底线 # 2. 缓存过期时间不能全靠 Cacheable 的 value要统一管控 cache: redis: time-to-live: 300000 # 5 分钟源码里所有 Cacheable 都没设 ttl全靠这里兜底 # 3. 开发环境务必关掉 Redis 分布式锁的自动续期Redisson 的 watchDog hm: lock: lease-time: 10000 # 锁持有时间 10s不是默认的 30s且 disable-watch-dog: true为什么lease-time设为 10s因为源码中秒杀下单逻辑里tryLock(1, 10, TimeUnit.SECONDS)的等待时间和持有时间都是 10s——如果设成 30s而业务方法实际只耗时 2s那锁会空占 28s导致后续请求排队饿死。这是血泪经验锁粒度必须紧贴业务耗时宁可失败重试不可长时霸占。2.3 用最小命令验证 Redis 是否真正接入不依赖 Spring Boot 启动直连看数据流别等整个项目跑起来再查 Redis。解压后先确保本地 Redis 已启动redis-server然后用redis-cli执行三组命令验证源码设计意图# 1. 检查缓存穿透防护空值缓存是否生效 127.0.0.1:6379 setex shop:999999 2 # 模拟查询不存在的店铺ID存空字符串2秒 OK 127.0.0.1:6379 get shop:999999 # 2. 检查点赞计数是否用 Hash 结构存用户对笔记的点赞关系 127.0.0.1:6379 hset blog:1 liked:101 1 # 用户101给笔记1点赞值为1表示已点 (integer) 1 127.0.0.1:6379 hget blog:1 liked:101 1 # 3. 检查分布式锁Lua 脚本是否原子执行 127.0.0.1:6379 eval if redis.call(exists, KEYS[1]) 0 then redis.call(set, KEYS[1], ARGV[1]); redis.call(expire, KEYS[1], ARGV[2]); return 1 else return 0 end 1 seckill:1001 123456 10 (integer) 1这三步验证比curl http://localhost:8080/shop/1更早暴露问题如果setex失败说明 Redis 连接参数错如果hset返回(integer) 0说明源码里BLOG_LIKED_KEY前缀拼错了如果eval返回0大概率是seckill:1001这个 key 已被其他进程占着——此时你要立刻去查ps aux | grep java确认有没有残留的测试进程在抢锁。3. 源码里最值得抄的五个 Redis 实战模式不是 API 调用而是业务语义的精准映射3.1 热点缓存 逻辑过期解决「缓存击穿」的终极方案比互斥锁更轻量黑马点评首页的「热门店铺」列表SQL 查询耗时 120ms是典型热点。源码没用Cacheable而是手写双层缓存逻辑// com.hm.service.impl.ShopServiceImpl.java public Result queryById(Long id) { String key CACHE_SHOP_KEY id; // 1. 先查缓存 String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { // 2. 缓存存在但需检查是否逻辑过期JSON 里含 expireTime 字段 Shop shop JSONUtil.toBean(shopJson, Shop.class); if (shop.getExpireTime().isAfter(LocalDateTime.now())) { return Result.ok(shop); } } // 3. 缓存失效加锁重建注意锁 key 是 shop:id:lock不是 shop:id String lockKey LOCK_SHOP_KEY id; boolean isLock tryLock(lockKey); if (!isLock) { // 4. 没抢到锁休眠后重试避免惊群 Thread.sleep(50); return queryById(id); // 递归重试 } try { // 5. 重建缓存查DB 设置逻辑过期时间如 2 小时不是物理过期 Shop shop getById(id); if (shop null) { // 空值缓存存空对象 2min 过期防穿透 stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); return Result.fail(店铺不存在); } // 关键序列化时注入 expireTime now 2h而不是用 redis 的 EXPIRE shop.setExpireTime(LocalDateTime.now().plusHours(2)); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); } finally { unlock(lockKey); } }注意这里的CACHE_SHOP_TTL设为 30 分钟但shop.setExpireTime(...)设的是 2 小时——物理过期时间只是兜底逻辑过期时间才是业务控制开关。这样设计的好处是当 Redis 主从同步延迟导致从节点缓存未及时失效时应用层仍能通过isAfter()判断拒绝脏数据。3.2 ZSET 实现「附近商户」排序不用 GeoHash用 score 存距离值源码中「附近店铺」接口/shop/of/type不用GEOSEARCH而是预计算距离存入 ZSET// com.hm.service.impl.ShopServiceImpl.java private void saveShopToRedis(Shop shop, Long typeId) { // 1. 计算该店铺到中心点如用户定位的距离单位米 double distance calcDistance(shop.getX(), shop.getY(), centerLon, centerLat); // 2. 用距离作为 score店铺ID作为 member存入 zset stringRedisTemplate.opsForZSet().add( SHOP_GEO_KEY typeId, shop.getId().toString(), distance ); // 3. 设置过期时间避免冷数据堆积 stringRedisTemplate.expire(SHOP_GEO_KEY typeId, 2, TimeUnit.HOURS); }调用时用zrangebyscore获取最近 20 家SetString shopIds stringRedisTemplate.opsForZSet() .rangeByScore(SHOP_GEO_KEY typeId, 0, 5000, 0, 20); // 5km 内取前20为什么不用 GeoHash因为黑马点评的「附近」是相对概念用户 A 和用户 B 的中心点不同而 GeoHash 需要提前分片。ZSET 动态存距离每次请求按当前用户坐标重算牺牲写性能换读灵活性——这正是本地生活 App 的真实权衡。3.3 Stream 模拟异步消息替代 RabbitMQ 的轻量方案用于「评价发布通知」源码中用户发布评价后需异步触发「更新店铺评分」和「推送消息」。没引入 MQ而是用 Redis Stream// com.hm.service.impl.BlogServiceImpl.java public Result likeBlog(Long id) { // 1. 点赞逻辑略 // 2. 发送事件到 Stream MapString, String data new HashMap(); data.put(blogId, id.toString()); data.put(userId, UserHolder.getUser().getId().toString()); stringRedisTemplate.opsForStream().add( StreamRecords.of(data).withStreamKey(BLOG_STREAM_KEY) ); return Result.ok(); } // 后台用 EventListener 监听 Stream实际是用单独线程轮询 XREADGROUP PostConstruct public void initStreamListener() { new Thread(() - { while (!Thread.currentThread().isInterrupted()) { ListMapRecordString, String, String records stringRedisTemplate.opsForStream().read( Consumer.from(group1, consumer1), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)), StreamOffset.latest(BLOG_STREAM_KEY) ); for (MapRecordString, String, String record : records) { // 处理更新店铺评分、发站内信... updateShopScore(record.getValue().get(blogId)); sendNotice(record.getValue().get(userId)); // ACK 确认消费 stringRedisTemplate.opsForStream().acknowledge( BLOG_STREAM_KEY, group1, record.getId() ); } } }).start(); }提示源码里StreamReadOptions.block(Duration.ofSeconds(2))的阻塞时间设为 2 秒不是 0 —— 避免空轮询 CPU 爆满。这是 Redis Stream 在 Java 中落地的关键参数。3.4 Redis 分布式锁的三重校验不止setnx还要del原子性与锁标识绑定源码中秒杀库存扣减的锁实现远比Redisson.getLock().lock()复杂// com.hm.utils.RedisLock.java public boolean tryLock(String key, String uuid, long waitTime, long leaseTime) { // 1. 使用 SET key value NX PX milliseconds 原子获取锁 String script if redis.call(exists, KEYS[1]) 0 then redis.call(set, KEYS[1], ARGV[1]); redis.call(pexpire, KEYS[1], ARGV[2]); return 1 else return 0 end; Long result stringRedisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(key), uuid, String.valueOf(leaseTime) ); if (result ! null result 1L) { return true; } // 2. 锁存在但可能已过期尝试用 Lua 删除过期锁防止误删别人锁 String delScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; result stringRedisTemplate.execute( new DefaultRedisScript(delScript, Long.class), Collections.singletonList(key), uuid ); return result ! null result 1L; }关键点uuid是客户端唯一标识不是时间戳确保del时只删自己设的锁pexpire用毫秒级避免expire在 Redis 2.6 的精度问题第二段 Lua 是「续约前自检」防止 A 拿到锁后 GC 停顿锁超时释放B 拿到锁A 恢复后误删 B 的锁。3.5 Hash 结构存「用户行为标记」比 String 更省内存支持批量操作源码中「用户是否点赞某篇笔记」不用set user:101:liked:201而是用 Hash// key: blog:201, field: user:101, value: 1 stringRedisTemplate.opsForHash().put(blog:201, user:101, 1); // 批量查多个用户是否点赞一次网络往返 ListObject results stringRedisTemplate.opsForHash().multiGet(blog:201, Arrays.asList(user:101, user:102, user:103)); // 批量删除清空某笔记所有点赞记录 stringRedisTemplate.opsForHash().delete(blog:201, user:101, user:102);为什么用 Hash内存占用1 万个用户点赞同一篇笔记String 方式要存 1 万个 keyblog:201:user:101...Hash 只存 1 个 key 1 万个 field网络 IOmultiGet一次取 100 个用户状态比 100 次get节省 99% RTT原子性hset和hget天然支持单 key 下的多 field 操作。4. 避坑源码里埋着的五个 Redis 致命陷阱踩中一个就线上告警4.1 现象首页店铺列表突然全部 404日志显示RedisConnectionFailureException原因源码中RedisUtils.java的重试逻辑写在catch块里但RedisTemplate的execute()方法在连接断开时抛出RedisConnectionFailureException而execute()的RedisCallback里没做try-catch导致异常穿透到 Controller 层。解决在RedisUtils的executeWithRetry()方法里必须捕获RedisConnectionFailureException并主动降级查 DB不能只 catchRuntimeException。修改如下public T T executeWithRetry(RedisCallbackT callback) { int retryCount 0; while (retryCount MAX_RETRY) { try { return stringRedisTemplate.execute(callback); } catch (RedisConnectionFailureException e) { // 必须显式 catch log.warn(Redis connection failed, retrying..., e); retryCount; if (retryCount MAX_RETRY) { log.error(Redis retry exhausted, fallback to DB); return fallbackToDb(); // 实现降级逻辑 } Thread.sleep(100 * retryCount); // 指数退避 } } return null; }4.2 现象秒杀活动开始后库存扣减出现超卖数据库里stock字段变成负数原因源码中SeckillController.java的seckill()方法先用RedisLock.tryLock()获取锁再查 Redis 库存get seckill:1001再扣减decr seckill:1001。但get和decr之间存在微小时间窗口若两个线程同时通过get判断库存 0都会执行decr。解决必须用 Lua 脚本保证「判断 扣减」原子性。替换原逻辑为-- check_and_decr_stock.lua local stock redis.call(get, KEYS[1]) if tonumber(stock) tonumber(ARGV[1]) then redis.call(decrby, KEYS[1], ARGV[1]) return 1 else return 0 endJava 调用Long result stringRedisTemplate.execute( new DefaultRedisScript(check_and_decr_stock.lua, Long.class), Collections.singletonList(seckill:1001), 1 // 扣减数量 ); if (result 0) { return Result.fail(库存不足); }4.3 现象用户登录后刷新页面有时提示「未登录」session 丢失原因源码用RedisTemplate存 sessionkey 为login:token:xxx但RedisTemplate默认序列化器是JdkSerializationRedisSerializer反序列化时若 class 字节码变更如加了字段会抛ClassNotFoundException导致get返回 null。解决强制改用GenericJackson2JsonRedisSerializer并在RedisConfig.java中配置Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 替换序列化器 Jackson2JsonRedisSerializerObject serializer new Jackson2JsonRedisSerializer(Object.class); ObjectMapper mapper new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); serializer.setObjectMapper(mapper); template.setDefaultSerializer(serializer); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); return template; }4.4 现象ZSET查询「附近店铺」返回结果为空但zcard显示有 500 条原因源码中calcDistance()计算距离时用的是Math.sqrt((x1-x2)^2 (y1-y2)^2)但经纬度是球面坐标平面距离公式误差极大北京到上海算出来可能只有 100km。zrangebyscore查0~5000米实际距离超 500km自然查不到。解决改用 Haversine 公式计算球面距离。替换calcDistance()方法private double calcDistance(double lon1, double lat1, double lon2, double lat2) { final double R 6371000; // 地球半径单位米 double latDistance Math.toRadians(lat2 - lat1); double lonDistance Math.toRadians(lon2 - lon1); double a Math.sin(latDistance / 2) * Math.sin(latDistance / 2) Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); double c 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // 返回米 }4.5 现象Stream消费者组积压大量 pending 消息XINFO GROUPS显示pel-count持续增长原因源码中initStreamListener()的消费线程处理完一条消息后没调用acknowledge()或acknowledge()被异常吞掉如sendNotice()抛NullPointerException导致消息一直留在 pending 队列。解决必须在try-catch-finally中确保acknowledge()执行且acknowledge()前加判空for (MapRecordString, String, String record : records) { try { updateShopScore(record.getValue().get(blogId)); sendNotice(record.getValue().get(userId)); } catch (Exception e) { log.error(Stream message process failed, e); // 记录失败日志但不 throw避免阻塞后续消息 } finally { // 关键即使处理失败也要 ack否则 pending 积压 if (record.getId() ! null) { stringRedisTemplate.opsForStream().acknowledge( BLOG_STREAM_KEY, group1, record.getId() ); } } }5. 进阶技巧用 RedisInsight 可视化诊断缓存问题三步定位「为什么缓存没生效」5.1 安装 RedisInsight 并连接本地 Redis 实例跳过 Docker直连更准黑马点评源码默认用redis://127.0.0.1:6379/0所以直接下载 RedisInsight v2 Windows/macOS/Linux 通用安装后打开点击Add Redis Database→Manual Configuration→ 填写字段值说明Connection Namehm-local自定义名称便于识别Host127.0.0.1本地 Redis 地址Port6379默认端口Database0源码中所有 key 都在 db 0Password留空源码未设密码注意不要选Auto-discover它会扫描所有端口慢且不准Manual Configuration才能精确匹配源码配置。5.2 用「Key Browser」实时观察缓存写入与过期行为连接成功后左侧菜单点Key Browser顶部搜索框输入shop:*回车。你会看到所有店铺缓存 key每行显示KeyTypeTTL (ms)Size (bytes)Last Accessshop:1String29876512402024-05-20 14:22:31shop:2String-111802024-05-20 14:20:15TTL 为 -1表示该 key 永不过期可能是空值缓存没设 ttl或逻辑过期没生效Size 异常大如 2KB说明序列化后的 JSON 包含冗余字段源码中Shop类可能有JsonIgnore漏写Last Access 时间停滞说明该 key 自创建后再没被访问可能是前端 URL 拼写错误如/shop/1写成/shops/1导致缓存永远沉睡。此时右键shop:1→View Value能看到完整 JSON。如果发现expireTime:2024-05-20T15:22:31但当前时间已过说明逻辑过期判断失效——立刻去查ShopServiceImpl.queryById()里isAfter()的时区是否为LocalDateTime.now()无时区而数据库存的是TIMESTAMP WITH TIME ZONE。5.3 用「CLI」执行源码中关键 Lua 脚本验证原子性是否真成立RedisInsight 内置 CLI比redis-cli更适合调试。在CLI标签页输入# 模拟两个并发请求抢同一把锁 127.0.0.1:6379 eval if redis.call(exists, KEYS[1]) 0 then redis.call(set, KEYS[1], ARGV[1]); redis.call(pexpire, KEYS[1], ARGV[2]); return 1 else return 0 end 1 seckill:1001 uuid-a 10000 (integer) 1 127.0.0.1:6379 eval if redis.call(exists, KEYS[1]) 0 then redis.call(set, KEYS[1], ARGV[1]); redis.call(pexpire, KEYS[1], ARGV[2]); return 1 else return 0 end 1 seckill:1001 uuid-b 10000 (integer) 0如果第二次返回0说明 Lua 脚本原子性正常如果返回1说明 Redis 服务端有问题如集群模式下 key slot 不一致。此时再执行get seckill:1001应返回uuid-a证明锁归属正确。5.4 用「Slow Log」抓取慢查询定位「为什么 Redis 响应变慢」黑马点评上线后若首页加载超过 2s别急着优化 Java 代码。在 RedisInsight 点Slow Log设置Threshold (ms)为10源码中所有 Redis 操作要求 10ms点击Refresh。常见慢操作TimeDuration (ms)Command2024-05-20 14:25:33128KEYS shop:*2024-05-20 14:25:4187HGETALL blog:1KEYS命令是 Redis 禁忌源码中绝对不能出现它会遍历全库O(N) 复杂度HGETALL慢说明blog:1这个 Hash 里存了上千个 field如用户点赞记录没分片应改为HSCAN分批读取。此时立刻去源码搜KEYS和HGETALL找到对应位置替换为SCAN或HSCAN。我带团队做过三次黑马点评的压测每次翻车点都在 Redis第一次是Cacheable的 key 生成器没排除分页参数导致shop:list?page1和page2缓存命中率归零第二次是ZSET的zrangebyscore没加LIMIT查 10 万条数据拖垮 Redis第三次是Stream的XREADGROUP没设COUNT一次拉取 5000 条消息把 JVM 内存打爆。现在我的习惯是每次改 Redis 相关代码必开 RedisInsight必跑一遍slowlog get 10必用monitor看真实命令流——不是信源码是信眼睛看到的数据。希望帮到你。本文还有配套的精品资源点击获取
返回列表