ARTICLE DETAIL

资讯详情

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

高并发缓存设计:Redis穿透、击穿、雪崩与分布式锁实战

高并发缓存设计:Redis穿透、击穿、雪崩与分布式锁实战 简介面向毕业设计场景基于Redis实战的高并发缓存项目以压缩包形式提供完整呈现从基础数据类型到集群部署的实战链路适合需要构建高性能缓存系统的Java开发者或计算机专业学生用于解决高并发下数据库压力大、响应延迟等问题。压缩包共79个文件以72个Java源码为主体辅以XML配置、Lua脚本、YAML及SQL脚本总大小仅93KB便于快速下载与本地调试源码结构清晰包含pom.xml、主程序与测试代码可直接导入开发环境运行。项目内容覆盖Redis核心知识点字符串、哈希、列表、集合与有序集合的操作RDB和AOF持久化策略以及发布订阅、事务、管道等高并发关键技术并给出集群搭建与负载均衡的实现思路。已有48人学习/下载适合作为毕业设计参考。通过完整的代码与配置学习者可了解缓存系统设计、实现与性能优化方法为实际项目中的缓存策略设计打下基础。1. 为什么说基于Redis的高并发缓存项目不只是“把数据塞进Redis”很多人拿到“基于Redis实战的高并发缓存项目.zip”这份资料第一反应是解压、跑起来、读里面的读写代码。但一套能在高并发下立得住脚的缓存方案真正的难度不在那几行GET和SET而在缓存穿透、击穿、雪崩、分布式锁、数据一致性、过期策略这些细节上。这个方向解决的问题很具体当QPS从几十涨到几万时Redis承担哪部分流量哪些请求仍然会打到数据库以及热点Key失效瞬间系统靠什么机制不崩。这套内容适合正在用Redis做缓存、但还没形成完整方法论的后端开发者也适合准备高并发相关岗位面试的技术候选人。下面按实际落地顺序来拆先定边界和链路再写读写与防护代码最后处理最容易翻车的并发边界场景。2. 把缓存链路说清楚Redis在服务端高并发里的边界与选型2.1 高并发场景下Redis靠什么撑住高QPSRedis之所以常被放在高并发链路的第一道缓存闸底层逻辑值得讲透。命令执行走的是单线程模型配合基于epoll的多路复用单进程可以维持数万个客户端连接而不用为每个连接开线程。单线程带来的额外收益是所有命令在内存里按顺序执行天然没有数据竞争不需要像多线程缓存那样处理复杂的锁同步。再加上数据全在内存里普通GET和SET的延迟通常在0.1到1ms量级单实例读写QPS在普通物理机上能跑到十万以上这是关系型数据库很难直接做到的。但单线程模型不是没有边界。最典型的问题是慢命令一个数组里有几十万条记录的SMEMBERS或者一个不带MATCH的KEYS执行期间会独占事件循环后续几百个请求全部排队。所以在高并发缓存设计里我一般会先立三条规矩第一Value尽量控制在几十KB以内再大就考虑拆分成多个Key或存文件索引第二禁止在生产环境用KEYS需要扫描时用SCAN游标分批做第三所有缓存结构尽量按O(1)时间复杂度设计凡是O(N)的遍历操作全部挪到离线任务里执行。内存容量是另一个边界。假设一个系统日增缓存Key约50万条每条Value平均10KB一天新增数据量就到5GB一个月就是150GB。所以写入缓存前要算清楚新增Key的数量、单Key平均大小、以及淘汰策略是否兜得住。我会在Redis启动参数里设置maxmemory和allkeys-lru同时监控INFO memory里的used_memory曲线避免内存缓慢涨满之后触发频繁淘汰。2.2 缓存层级架构本地缓存、Redis、数据库各管一段实际读链路通常是三级请求先打本地缓存没命中再到RedisRedis没有才查数据库。本地缓存我一般用Caffeine而不是手写MapCaffeine内置了基于频率的淘汰策略、过期淘汰和访问统计省掉自己造轮子的麻烦。它的命中率虽然不算高但对单个强热点Key的支撑非常有效比如首页商品信息、配置中心的数据可以在Redis抖动时直接把流量拦在进程内。Redis解决绝大多数共享热点多个服务实例共用同一份数据设置一个合理的TTL。数据库永远放在最后真到了查库那一步就需要配套限流、熔断、分布式锁这些保护机制。三层级的取舍关系可以用一张表概括层级延迟量级容量一致性水平典型用途本地缓存微秒进程内极小弱各节点可能不一致配置、强热点Redis毫秒大受内存限制TTL可控共享热点MySQL十毫秒以上大强最终数据源设计时有个实用原则越往上容量越小、速度越快、一致性越弱越往下一致性越强、也越需要被上层保护。命中率估算我习惯用redis-cli --stat和INFO stats里的keyspace_hits与keyspace_misses来算。命中率长期低于70%说明缓存覆盖不够需要检查有多少查询绕过了缓存高于95%时要留意是不是本地缓存占比太高导致Redis层的监控失真。2.3 最小可运行的缓存读写链路先画流程再落代码链路设计完成后我会先用一个Service方法把主流程跑通再逐步加防护。下面是标准Cache Aside模式的最简实现public Order getOrder(String orderId) { // 第一层读Redis缓存 String cacheKey order:detail: orderId; String json (String) redisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, Order.class); } // 第二层缓存未命中查数据库 Order order orderMapper.selectByOrderId(orderId); if (order ! null) { // 回填缓存设置30分钟过期 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(order), 30, TimeUnit.MINUTES); } return order; }这里有几个关键参数需要说清楚。过期时间30分钟只是示例我一般按“该数据最长允许落后多少秒”的两倍来设定缓存Key要带业务前缀避免不同业务相互覆盖存储格式统一用JSON字符串而不是JDK自带的序列化否则会出现体积膨胀、内容不可读、类结构升级后反序列化失败一连串问题。这段代码没有处理穿透和击穿它只是底座。回填有个细节容易被忽略查库成功才回填查库失败会直接返回空。在高并发下如果请求查的是一个不存在的订单IDRedis永远查不到数据库会被反复冲击这是第3章要解决的穿透问题。另外一个隐患是热点Key恰好过期时多个并发请求同时查库形成击穿这在第4章专门处理。3. 缓存读写落地命令、序列化与穿透防护3.1 GET、SET、EXPIRE的正确用法与序列化陷阱缓存项目里日常打交道最多的是String和Hash偶尔用到ZSET。String直接存JSON适合单条对象Hash适合缓存一个业务实体的多个字段比如商品ID对应的名称、价格、库存ZSET适合排行榜和按时间线排序的数据。大多数业务场景GET、SET、EXPIRE三条命令加一个过期时间就够用了。但在Spring Boot里直接使用RedisTemplate还是StringRedisTemplate里面藏着一个常见翻车点。RedisTemplate默认序列化器是JdkSerializationRedisSerializer它会把对象序列化成带类名信息的二进制存进Redis后用redis-cli看是一长串不可读内容一旦Java对象结构变化老数据还会反序列化失败。我一般会统一配置成String序列化Configuration public class RedisConfig { Bean public RedisTemplateString, String stringRedisTemplate(RedisConnectionFactory factory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } }这段配置把Key和Value都设为字符串序列化业务侧配合JSON字符串存储。优势有两个一是redis-cli和可视化客户端里能看到明文排查数据问题非常直观二是跨语言友好Java写入的数据其他语言也能读。代价是取出来时要手动做一次JSON解析用Jackson或Gson封装一下成本很低。EXPIRE的常见误用也要提醒一下Spring Data Redis里set(key, value, timeout, unit)的timeout参数单位是TimeUnit而不是秒有些同事习惯了Jedis里以秒为单位here会把TimeUnit.MINUTES写成10结果缓存10分钟就过期。还有一个边界是setIfAbsent也可以带过期时间但很多人只传Key和Value导致锁或标记数据永不失效。3.2 缓存穿透伪造ID与空值缓存的两种应对穿透指的是请求查询一个必然不存在的数据比如伪造的订单ID。缓存和数据库都没有但每次请求都会打到数据库。低并发时是小问题高并发下就是灾难一个简单循环就能把数据库拖垮。常见的应对有两种。第一个是空值缓存即使数据库查不到也在Redis里存一个标识value设为空字符串TTL设短一点比如60秒。第二个是布隆过滤器启动时把所有合法ID加载进过滤器请求先过过滤器过滤器说“不存在”就直接返回连Redis都不查。布隆过滤器的误判率可以配置我用Redisson自带的实现RBloomFilterString filter redissonClient.getBloomFilter(valid:order:ids); filter.tryInit(1000000L, 0.01);代码里两个参数第一个是预期数据量这里预估100万条订单ID第二个是误判率0.01表示100个请求里最多1个可能误判。误判率越低底层位数组占用越大我一般取0.01到0.05之间再低没必要。布隆过滤器有一个弱点它只能判断“一定不存在”和“可能存在”不支持删除。如果订单ID会过期作废长期运行误判率会升高。所以我更多采用空值缓存结合参数校验的做法布隆过滤器用在ID集合相对固定的场景比如用户ID注册后基本不变。3.3 封装一个可复用的缓存服务缓存操作散落在各个业务方法里如果每个地方都直接操作RedisTemplate容易出现Key命名不统一、忘记设置过期时间、忘记判空这些问题。我一般会抽一个CacheService把读写、删除、最简锁收拢起来Service public class CacheService { private final RedisTemplateString, String redisTemplate; public String getWithCheck(String key) { return redisTemplate.opsForValue().get(key); } public void set(String key, String value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } public void delete(String key) { redisTemplate.delete(key); } public boolean tryLock(String lockKey, String requestId, long expireSeconds) { return redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); } public void unlock(String lockKey, String requestId) { String value redisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { redisTemplate.delete(lockKey); } } }tryLock和unlock相当于一个最简分布式锁第4章的击穿场景会用到它。注意unlock的写法先比较value是否还是当前持有者的requestId然后再删除避免把其他线程刚设置的锁误删。这个服务里set方法必须传过期时间防止永不失效的脏数据getWithCheck只是第一步封装真正的防穿透逻辑会在业务方法里组合空值缓存一起用。4. 高并发下的击穿与雪崩分布式锁、过期时间打散与预热4.1 热点Key失效的瞬间击穿怎么打垮数据库缓存穿透是查不存在的数据缓存击穿则是“一个热点Key在过期的瞬间大量并发请求同时打到数据库”。假设上午10点发布秒杀活动活动商品详情的缓存过期时间正好是10:00一秒内涌入几千个查询。它们全部Redis未命中然后一起查库数据库连接数瞬间打满响应变慢进而产生连锁等待和超时。击穿的核心原因是重建缓存缺少互斥。解决办法是在查库之前先抢锁抢到锁的线程负责查库和回填抢不到的线程等待一小段时间后重试或者直接返回旧值。这个锁不能是JDK的synchronized因为生产系统通常是多实例部署必须是跨JVM的分布式锁。监控上我一般会同时盯着Redis的瞬间QPS、数据库连接数和慢SQL数量这三个指标能快速确认是不是发生了击穿。4.2 用分布式锁保护缓存重建SET NX EX与Redisson怎么选最原生的分布式锁是Redis的SET NX EX命令。上面CacheService里的tryLock用的就是setIfAbsent等价于SET key value NX EX seconds只有Key不存在时才能设置成功同时带上过期时间避免客户端宕机后锁永不释放。使用时有三个原则要记住锁的value必须能唯一标识持有者过期时间要大于业务最大执行时间释放锁时必须校验持有者。业务简单时自己封装够用但项目稍复杂我建议直接用Redisson。Redisson的getLock拿到的锁自带看门狗机制默认leaseTime是30秒每10秒续期一次避免业务没执行完锁就过期。下面这段是Redisson在缓存重建里的用法public Order queryOrderWithLock(String orderId) throws InterruptedException { String key order:detail: orderId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, Order.class); } RLock lock redissonClient.getLock(lock:order: orderId); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { Thread.sleep(50); return queryOrderWithLock(orderId); } try { // 双重检查拿到锁后再次读缓存避免重复查库 String cachedAgain redisTemplate.opsForValue().get(key); if (cachedAgain ! null) { return JSON.parseObject(cachedAgain, Order.class); } Order order orderMapper.selectByOrderId(orderId); if (order ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(order), 30, TimeUnit.MINUTES); } return order; } finally { lock.unlock(); } }这里两个参数注意理解tryLock的第一个参数是等待拿锁的时间第二个是锁过期时间。等待时间我建议设在100ms到1s之间太短容易拿不到锁太长会让页面请求堆积锁过期时间要大于一次查库加回填的最坏耗时数据库慢查询加上大结果集序列化可能超过几秒我给30秒属于偏保守。双重检查的意义在于当前线程拿到锁之后可能已经有其他线程回填过缓存了再查一次能省一次数据库查询。4.3 缓存雪崩过期时间打散与多级缓存兜底雪崩比击穿更宏观击穿是单个热点Key失效雪崩是大批量Key同一时刻失效或者Redis整个实例不可用。批量失效最容易出现在“统一设置固定TTL”的项目里比如启动时把所有商品数据都设置成30分钟过期到点后所有请求一起穿透到数据库。最常见的对策是给过期时间加随机偏移量public static long randomExpireTime(long baseTtl) { return baseTtl ThreadLocalRandom.current().nextLong(0, 300); }这段代码把固定的TTL加上300秒以内的随机偏移。调用时把set的过期时间换成randomExpireTime(1800)同一批Key的失效时间就会分散在半小时区间内而不是同时失效。进一步的做法是引入多级缓存Redis挂掉时本地缓存还能挡掉一部分流量配合Redis Sentinel或Cluster模式解决单点故障。注意多级缓存会带来一致性问题本地缓存里残留的旧值可能要几十秒才能淘汰所以只适合容忍短时间不一致的业务。4.4 缓存预热把热点数据提前放进Redis秒杀、榜单、首页推荐这类场景与其等用户请求触发回填不如在服务启动或定时任务中直接写入热点数据。预热时要注意控制写入速率一次性大批量写入会导致Redis主线程卡顿。我一般用管道分批写每1000条提交一次ListOrder hotOrders orderMapper.selectHotOrders(10000); for (ListOrder batch : Lists.partition(hotOrders, 1000)) { redisTemplate.executePipelined((RedisCallbackObject) connection - { for (Order order : batch) { byte[] key (order:detail: order.getId()).getBytes(); byte[] value JSON.toJSONString(order).getBytes(); connection.stringCommands().set(key, value); } return null; }); }管道的原理是把多条命令一次性发送到Redis减少网络往返。1000条一批每一批一次RTT速度远快于逐条调用。预热完成后配合本地缓存热点请求在Redis层面就能命中大半数据库压力明显下降。5. 缓存一致性与线上避坑先更新谁、延迟双删、Binlog订阅与排查5.1 先更新数据库还是先删缓存顺序之争缓存一致性问题是高并发缓存项目里最容易被追问、也最容易被线上问题打脸的一环。核心矛盾在于数据库和Redis不是一个原子事务无论先做哪一步中间都有短暂的不一致窗口。先更新数据库再更新缓存最直观但也最容易脏读。并发场景下两个线程同时更新同一行数据可能出现线程A写库新值、线程B写库旧值的交错最终缓存里留下旧值。先删缓存再更新数据库问题在于删完缓存到数据库更新完成之间会有其他线程把旧数据回填进缓存导致缓存长期是旧值。我目前实践下来最常用的是Cache Aside读时先读缓存未命中再读库并回填写时先更新数据库然后删除缓存而不是更新缓存。删除比更新安全原因在于“写入缓存的值可能依赖复杂计算”删除后下一次读取会重新回填天然避开写入中间状态的脏数据问题。5.2 延迟双删与Binlog订阅最终一致的两个方向Cache Aside模式在快路径上是可行的但它有个窗口期如果删除缓存失败或者刚删完缓存又有并发请求把旧库值回填数据就脏了。常见的补偿手段是延迟双删public void updateOrder(Order order) { // 第一步删除缓存 redisTemplate.delete(order:detail: order.getId()); // 第二步更新数据库 orderMapper.updateById(order); // 第三步延迟一段时间后再次删除缓存 executor.schedule(() - redisTemplate.delete(order:detail: order.getId()), 1, TimeUnit.SECONDS); }延迟时间是一个需要调的参数。理论上要大于“从删除缓存到数据库更新完成”加上“可能的并发请求回填缓存”的时间。我一般设置500ms到1000ms如果主从同步延迟大或服务链路长会调到2秒。要注意延迟双删只是提高最终一致的概率不能实时保证一致。真需要强一致时这个数据就不应该走缓存或者读请求短时间改走主库。另一个成熟方向是Binlog订阅。通过Canal或同类组件监听MySQL Binlog拿到更新事件后异步删除或重建缓存。这个方案把缓存维护和业务代码解耦业务只写数据库订阅侧负责缓存一致性。缺点是引入独立组件部署和运维成本高更适合中大型项目。实现思路是更新订单表后Canal解析出订单ID消费端删除对应的缓存Key。5.3 避坑3个高并发缓存项目的典型翻车现场现象一线上订单详情缓存里用户看到的价格一直不变刷新也不更新。 原因代码用的是“先更新数据库、再更新缓存”的方式并发下单时两个线程的写库和写缓存顺序交错最终缓存被写入旧值。 解决把更新缓存改成删除缓存强制下一次读取时回填同时对同一订单的更新操作加分布式锁保证写库和清缓存串行化。现象二分布式锁偶尔失效多个线程同时拿到了锁。 原因锁的过期时间设置过短业务执行超过锁过期时间锁自动释放或者释放锁时没校验持有者直接delete把其他线程刚设置的锁误删了。 解决锁的value带requestId释放锁时用Lua脚本比较后再删除保证原子。业务侧按最坏执行时间设置锁过期时间或者直接用Redisson的看门狗自动续期。现象三某个大Key的过期时间没打散整点出现大量请求穿透到数据库。 原因批量初始化时统一用了固定TTL没有加随机偏移导致同一秒大量Key同时失效。 解决用4.3节的randomExpireTime生成过期时间。另外在监控里加一条规则检查Redis写入时是否使用了固定TTL避免新代码再次引入同样的模式。6. 压测与一个进阶技巧用Lua脚本把并发操作变成原子操作6.1 用redis-benchmark先看基础吞吐缓存项目上线前我会先用Redis自带的redis-benchmark看基础能力。下面这条命令模拟100个并发连接、总共10万次请求测试GET和SETredis-benchmark -h 127.0.0.1 -p 6379 -c 100 -n 100000 -t get,set -d 256 -q-c后面是并发连接数-n是总请求数-d是数据包大小单位是字节。关注输出里的PPS每秒完成请求数和延迟分布。这次压测能确认单实例的天花板之后再用真实业务脚本打自己的接口观察缓存命中率和数据库负载。6.2 用Lua脚本把“比较-更新”变成原子的最后一个进阶技巧当要执行“比较后删除”或“检查后更新”这类复合操作时用Lua脚本把多条命令封装到一个EVAL里Redis保证这段脚本在单线程里一次性执行完中间不会被其他命令插入。比如释放锁的标准写法if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endKEYS[1]是锁的KeyARGV[1]是持有者标识。先GET比较再DEL删除两步被包成原子操作不会出现比较完成之后、删除之前锁被其他线程抢走的问题。Java侧用DefaultRedisScript执行这段脚本或者用Redisson已经内置好的逻辑。6.3 一点个人习惯我做缓存项目有个习惯上线前先写清楚“这个数据最多能容忍多久的不一致”然后才去定TTL和同步策略每一个热点Key都要问一遍穿透、击穿、雪崩三个问题每把锁的value都必须带唯一标识。这套流程帮我挡掉过不少线上事故希望帮到你。本文还有配套的精品资源点击获取
返回列表