ARTICLE DETAIL

资讯详情

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

微服务架构下分布式缓存Redis集成实战:穿透、击穿、雪崩与一致性治理

微服务架构下分布式缓存Redis集成实战:穿透、击穿、雪崩与一致性治理 聊到“分布式缓存与微服务架构的集成”很多人第一反应是“这不就是个Redis的事嘛”。实际动手之后你才会发现把Redis启动起来太简单了但把它稳定、高效、安全地嵌进几十个微服务里还要处理缓存穿透、击穿、雪崩、数据不一致、大Key热Key这些坑才是真正难啃的部分。这篇文章我结合自己做过的项目从缓存到底该不该上、该怎么选型到代码里怎么写、线上怎么排障完整拆一遍集成这件事。无论你是刚开始做微服务改造还是已经在生产环境被缓存问题折磨都能从里面找到可以直接用的东西。1. 微服务架构里到底需不需要分布式缓存1.1 加缓存是为了解决什么问题微服务架构拆分之后一个明显的副作用是原本一次本地方法调用变成了跨服务的远程调用。你查一个订单详情可能先调用户服务拿用户信息再调商品服务拿商品数据最后调订单服务拼装结果。每一个远程调用都有网络开销、序列化开销、连接池等待开销放大个几倍甚至一个数量级很正常。数据库这时候就成了最容易被打垮的瓶颈。关系型数据库擅长的是事务和复杂查询并不擅长抗高并发读。如果同一个热点数据被几千个请求同时访问数据库的连接池先会被占满然后整条链路跟着雪崩。分布式缓存的定位就是把这些重复的、高频率的读请求挡在数据库前面用内存的高吞吐来消化掉大部分访问压力。我做过一个电商后台的商品详情页接口原来直接查MySQL单机大概能扛住每秒一两千的请求数据库CPU已经飙到快90%。加了Redis缓存之后同样的机器配置接口TPS能到两万以上数据库CPU降到个位数P99延迟从八十多毫秒降到了五毫秒以内。这就是缓存带来的最直观效果。1.2 什么时候不要上分布式缓存但并不是所有场景都适合上缓存。有些团队把缓存当成万能膏药哪里慢就贴哪里最后反而拖垮了系统。我见过几个典型的反面案例数据强一致性要求极高的时候比如账户余额、库存扣减这种场景如果你把数据放进缓存缓存与数据库的任何一次短暂不一致都可能造成资损。这类数据就应该走数据库事务配合分布式锁和幂等设计而不是用缓存来加速。写多读少的场景也不适合。比如日志上报、埋点数据写入频繁但基本不会被反复读取。你就算把每条日志都塞进缓存收益也趋近于零反而白白占用内存还要承担缓存和数据库之间的同步成本。数据量极小且访问量也小的内部管理后台比如一个只有几百个用户使用的配置中心后台直接查数据库可能不到一毫秒就返回了加缓存除了显得技术栈豪华没有任何实际意义。所以做集成之前先问自己三个问题这个数据是不是高频读读多写少还是写多读少业务上能不能接受短暂的不一致这三个问题回答清楚了要不要上缓存自然就有答案了。1.3 缓存选型为什么Redis成了事实标准确定了要上缓存之后接下来就是选型。目前业界分布式缓存方案基本就是Redis的天下偶尔还有Memcached在存量系统里呆着。Memcached本身并不差纯内存、性能足够、实现简单但它的数据结构只有简单的字符串而且没有持久化能力集群方案也比较粗糙。一旦服务重启缓存全部清空在微服务架构里这个体验非常难受。Redis能成为事实标准核心原因是它在几个关键维度上都做得足够均衡基于内存的读写性能极高官方数据单实例吞吐可以到十万级QPS数据结构非常丰富除了String还有Hash、List、Set、ZSet很多业务逻辑可以直接在Redis里面完成比如排行榜、去重、队列它支持RDB和AOF两种持久化允许你在追求高性能的同时保留数据恢复能力再加上哨兵模式和Cluster集群方案生产环境的可用性比较容易得到保障。另外Redis的客户端生态非常成熟。Java有Jedis、Lettuce、RedissonPython有redis-pyGo有go-redis。这些客户端不仅封装了基础读写连分布式锁、限流器、消息队列这类高阶玩法都集成了。选Redis后续遇到问题能搜到的方案最多踩坑成本相对最低。2. 集成方案的整体设计与关键决策2.1 本地缓存、分布式缓存与多级缓存的取舍微服务集成缓存第一个要做的选择不是引入Redis而是想清楚缓存要放在哪一层。我见过很多团队一上来就直奔Redis进程内缓存看都不看一眼结果把简单的方案搞复杂了。进程内缓存也就是像Caffeine、Guava Cache这种跑在应用JVM里的缓存最大的优势是快因为连网络请求都省了直接内存读取延迟是亚毫秒级的。缺点是每个服务实例各存一份数据不一致是必然的而且受限于单机内存容量。分布式缓存也就是Redis优点是全局统一、容量可扩展、数据一致性相对好控制缺点是每一次读写都有一次网络IO开销延迟至少在一毫秒左右。多级缓存是很多大厂的选择本地缓存扛住极端热点Redis扛住大部分读请求数据库兜底。但这个方案有一个非常现实的问题一致性维护的复杂度会成倍上升。本地缓存过期了Redis里的数据是最新的但本地缓存还没更新应用读到的就是旧数据。你需要自己设计本地缓存的刷新机制、版本号校验、消息通知等。我实际体验是大多数业务系统根本不需要一开始就上多级缓存把Redis这层做好已经能解决90%的问题等真出现热点问题再针对性优化也不迟。2.2 缓存数据的粒度和Key设计缓存粒度决定了缓存的复用率和一致性维护难度这个环节很容易被低估。有些开发者把整个接口返回对象塞进一个Key里比如一个订单详情接口直接把整个DTO序列化存进Redis这当然简单但问题很多数据量大的时候单Key体积膨胀修改任何一个字段都要重刷整个缓存缓存命中率很低还容易产生大Key。更好的做法是按数据维度拆分缓存粒度。用户信息缓存就只存用户信息商品基本信息缓存就只存商品基本信息订单数据单独缓存。如果业务需要聚合数据由服务层负责组装而不是靠Redis存一个巨大的聚合对象。这样每个缓存的变更粒度更细命中率更高也更好维护。Key的设计规范也非常重要。我建议统一使用“业务域:实体名:唯一标识[:附加维度]”这种格式比如auth:token:{userId}、product:detail:{productId}、order:list:{userId}:{page}。好处是肉眼可读、Redis内按前缀统计方便、排查问题时能快速定位是哪个服务的缓存。切忌用没有含义的纯数字或者随机串做Key线上出了事根本没法查。2.3 缓存更新策略哪套方案最不容易出错缓存更新的套路市面上讲得很多Cache Aside、Read Through、Write Through、Write Behind每种都有适用场景。微服务架构里我见到用得最多、也最不容易出错的是Cache Aside模式。Cache Aside的核心逻辑是读请求先查缓存命中就直接返回没命中就查数据库然后回填缓存写请求先更新数据库成功后删除缓存。这里有一个关键点很多新手会搞错一定要先更新数据库再删除缓存而不是先删缓存再更新数据库。我先说为什么不能先删缓存再更新数据库。如果请求A先删了缓存然后还没更新数据库这时请求B来了它查缓存没命中回去查数据库查到的还是旧数据并把旧数据回填到了缓存里。等请求A更新完数据库缓存里躺着的依然是旧数据这个不一致可能要等缓存过期才能修复。反过来先更新数据库再删缓存问题也不少最常见的是更新数据库成功、删除缓存失败结果缓存里是旧数据。这个问题的兜底方案我在后面的排障章节会详细讲。总而言之Cache Aside不是完美的但它是最容易理解和控制的。还有一点需要强调如果你的业务对一致性要求真的很高那就别加缓存加了缓存就等于接受了“最终一致”。2.4 网络抖动与超时必须提前定的三个参数分布式缓存比本地缓存多了一道网络链路这也就意味着它多了一类故障Redis本身没挂但网络抖动导致客户端连接超时了。很多团队上线前没想清楚这个事Redis一抖动所有依赖缓存的接口全部报错连锁反应比Redis挂掉还严重。我建议在集成之初就把三个参数定下来。第一个是连接超时比如Lettuce里的connectTimeout和readTimeout一般设置成200到500毫秒就够用了时间太长会拖垮整个请求链路。第二个是重试策略重试不是越多越好最多一到两次而且仅对读操作重试写操作的重复执行可能造成数据重复。第三个是降级策略明确缓存不可用的时候服务是返回失败还是直接查数据库。大多数只读场景我建议缓存放通也就是Redis异常时直接走数据库保证主流程可用但前提是数据库自身能扛住不然降级就变成了雪崩的开始。3. 实操从零把Redis集成到微服务3.1 基于Spring Boot的基础配置接下来进入代码落地环节。我用Java生态里最常见的Spring Boot 3.x Spring Cloud场景来演示这套思路换成Python、Go其实一样缓存模式是语言无关的。第一步是引入依赖在你的pom.xml里加上spring-boot-starter-data-redis和连接池相关的commons-pool2。前者是Spring对Redis客户端的封装默认基于Lettuce客户端后者用来管理连接池参数。光有spring-boot-starter-data-redis还不行不配连接池的话Redis客户端的连接管理会很粗糙。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency接着在application.yml里配置Redis连接信息。我一般会把超时时间和连接池参数都显式写出来而不是依赖默认值。Lettuce默认的配置在生产环境经常不够用。spring: data: redis: host: redis-sentinel port: 26379 password: ${REDIS_PASSWORD} timeout: 500ms connect-timeout: 500ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 300ms sentinel: master: mymaster nodes: - sentinel1:26379 - sentinel2:26379连接池里的max-active不是越大越好这点要特别提醒。如果服务有20个实例每个实例配200个连接Redis端就要承受4000个连接Lettuce是单线程多路复用大多数情况下几十个连接已经足够了。配太多反而增加Redis端的线程切换开销还容易把Redis的连接数打满。3.2 RedisTemplate与自定义序列化直接用Spring Boot提供的StringRedisTemplate只能存字符串大部分业务对象需要JSON序列化。Spring Boot默认的RedisTemplate用的是JdkSerializationRedisSerializer存进去的值是一串二进制乱码肉眼完全不可读排查问题的时候想死的心都有。而且JDK序列化后的体积太大浪费内存。我建议自定义一个RedisTemplate配置类Key用StringRedisSerializerValue用GenericJackson2JsonRedisSerializer。这样Redis里看到的是可读的字符串Key和JSON格式的Value排查问题方便很多。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }这里有个坑要提醒GenericJackson2JsonRedisSerializer序列化LocalDateTime这类Java时间类型时会报错需要在ObjectMapper里注册JavaTimeModule并禁用WRITE_DATES_AS_TIMESTAMPS。我在项目里遇到过好几次服务启动没问题一往Redis里写带时间字段的对象就抛异常。配置好ObjectMapper可以省掉很多后续麻烦。3.3 用Spring Cache做声明式缓存Spring Cache提供了一套声明式缓存抽象通过注解就能给方法加缓存对业务代码侵入非常小。它支持Cacheable、CachePut、CacheEvict等注解底层通过RedisCacheManager接入Redis。使用Cacheable时框架会以方法参数作为Key去查缓存命中就直接返回结果方法体不会执行没命中就执行方法然后把返回值写入缓存。这个特性对读多写少的接口很友好。Service public class ProductService { Cacheable(cacheNames product:detail, key #productId) public Product getProductDetail(Long productId) { // 查询数据库或调用其他微服务 return productMapper.selectById(productId); } CacheEvict(cacheNames product:detail, key #productId) public void updateProduct(Long productId, Product product) { productMapper.updateById(product); } }用这种注解方式缓存逻辑和业务逻辑完全解耦代码看着很干净。但要注意两个问题一是Cacheable命中缓存时对参数为null的情况不会缓存如果数据库里查不到就返回null下一次请求还要再查一次数据库这会造成缓存穿透。解决办法是设置unless #result null配合缓存空值或者用布隆过滤器。二是默认情况下Spring Cache的缓存没有过期时间你需要通过RedisCacheManager的配置给每个cacheNames设置TTL不然缓存会无限增长把Redis内存吃满。3.4 更灵活的方式Redisson与分布式锁Spring Cache适合快速落地但遇到复杂场景就有点力不从心。比如缓存里要存一个复杂的数据结构需要对某个Hash字段单独更新比如缓存重建时需要加分布式锁防止大量请求同时打到数据库比如要使用延迟队列、发布订阅这些高阶能力。这个时候我更喜欢直接操作Redisson客户端。Redisson是一个功能非常全的Redis客户端它对分布式锁、RateLimiter、Semaphore这些并发组件做了很成熟的上层封装。用Redisson实现分布式锁比自己写SETNXLua脚本可靠得多官方把看门狗续期这些细节都处理好了。假设商品详情缓存没有命中为了避免所有请求同时打到数据库可以先尝试加锁拿到锁的请求负责从数据库加载数据并重建缓存没拿到锁的请求短暂等待之后重新读缓存。Autowired private RedissonClient redissonClient; public Product getProductWithLock(Long productId) { String cacheKey product:detail: productId; Product product cacheService.get(cacheKey, Product.class); if (product ! null) { return product; } RLock lock redissonClient.getLock(lock:product: productId); try { if (lock.tryLock(1, 5, TimeUnit.SECONDS)) { product cacheService.get(cacheKey, Product.class); if (product ! null) { return product; } product productMapper.selectById(productId); cacheService.set(cacheKey, product, 10, TimeUnit.MINUTES); return product; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } return productMapper.selectById(productId); }这种“双重检查分布式锁”方案在缓存击穿场景下非常经典也是我自己在项目里用得最多的写法。注意锁的粒度要细到业务对象维度而不是锁整个服务否则并发量一上来锁本身就成了瓶颈。4. 高可用和数据一致性建设4.1 主从、哨兵、Cluster部署模式怎么选Redis集成到微服务之后它的可用性直接决定了整条链路的稳定性这块不能只靠开发人员拍脑袋得和运维一起定方案。Redis有单机、主从复制、哨兵、Cluster四种常见的部署模式。单机模式最多用来做本地开发。生产环境哪怕业务量再小我都建议至少做一主一从的部署主节点挂了从节点还能顶上。主从复制的局限在于它不提供自动故障转移主节点宕机时需要人工介入因此引入哨兵模式是生产环境的最低底线。哨兵模式在Redis 2.8之后成为标准方案它解决了主节点故障自动切换的问题。哨兵进程会持续监控所有Redis节点主节点挂掉后自动把某个从节点提升为主节点。微服务客户端通过连接哨兵节点获取当前主节点地址RedisTemplate和Redisson都内置了哨兵支持。哨兵模式适合缓存数据量在几十G以内、QPS在十万左右的场景多数业务系统到这个撑度已经足够了。当缓存数据量超过单机内存或者QPS需要扩展到百万级别时就该上Redis Cluster了。Cluster采用无中心架构数据按Key的CRC16哈希分散到16384个槽位分布在多个主节点上。每个主节点再配一个或多个从节点主节点挂了从节点自动顶替。这带来一个很重要的变化你没法用MGET跨节点批量获取Key了也没法在一个事务里操作多个Key了这对业务代码是有侵入性的。选型时要想清楚不是越高级越好而是匹配当前数据规模和增长预期。4.2 缓存与数据库的双写一致性缓存与数据库的一致性问题是所有缓存方案里最磨人的一个。我前面说Cache Aside是“先更新数据库再删除缓存”但它有个明显的失败场景删除缓存那一步如果失败了比如网络抖动、Redis超时缓存里的旧数据就会一直存在直到过期时间到了才会被纠正。针对删除失败的情况业界有几个常用应对手段。第一个是消息队列重试把删除缓存的任务丢进MQ由专门的消费者执行删除操作失败了自动重试。这个方案简单可行但实时性会受到MQ延迟影响。第二个是延时双删在更新数据库之后先删一次缓存隔几百毫秒再删一次用来处理并发请求在第一次删除后把旧数据写回缓存的窗口期。这个方案在多数场景下够用但间隔时间设置多少完全靠经验设置不好还是会漏。第三个是监听数据库的binlog日志通过Canal之类的工具把MySQL的变更事件同步出去再由消费者解析事件删除对应缓存。这个方案对业务代码零侵入实时性也好但需要额外运维一套Canal组件重了点。我实际项目的建议是如果团队规模不大、技术栈偏简单直接用“先更新数据库再删缓存延迟双删较短TTL兜底”这套组合拳。如果你的系统已经很依赖MQ再加一条“删除失败投递MQ重试”的兜底链路会更稳。记住一个底层原则缓存和数据库的一致本来就是最终一致能做的是把不一致的时间窗口压缩到足够短而不是追求任何时候都绝对相等。4.3 穿透、击穿、雪崩的实战防御这三个问题翻译成人话分别是缓存和数据库里都没有这条数据每次请求都白跑一遍数据库某个Key突然被大量请求同时访问而这个Key正好在缓存里过期了大量Key在同一时间集体过期所有请求同时涌向数据库。它们都会导致数据库压力暴增表现相似但成因和防线完全不同。对付穿透业界最常用的两个方案是缓存空值和布隆过滤器。缓存空值很简单数据库查不到数据时在Redis里放一个短TTL的空占位符后续请求直接命中空值缓存不再打库。布隆过滤器更高阶一些它把存在的数据ID放进去请求来了先判断ID是否存在不存在就拦截掉。但布隆过滤器有误判率配置参数需要根据数据量估算不能随便用默认值。对付击穿核心思路就是分布式锁加重建缓存这就是我在实操章节演示的那段代码。要注意的是锁的粒度要细到被请求的Key而不是锁整个类更不是锁整个缓存服务。还有一个思路是“逻辑过期”不设置物理TTL而是在Value里塞一个过期时间字段读取时发现逻辑过期就由后台线程异步重建缓存好处是请求永远能拿到数据坏处是实现复杂度较高。对付雪崩最简单有效的手段是给过期时间加一个随机偏移量比如基础TTL是10分钟每个Key加上0到120秒的随机值。这样原本可能同时失效的Key会错开过期时间。另一个思路是Redis部署层面做主从或者集群避免Redis自身挂掉导致的雪崩。业务侧还要做限流和熔断数据库扛不住的时候直接返回兜底数据比如商品页面的缓存被清了可以先返回一个基础版本页面而不是让所有请求都穿透到数据库。4.4 监控和容量规划Redis部署上去了缓存代码也写了如果没有监控就等于蒙着眼开车。我一般会从两个方面下手一是系统自身的监控二是业务层面的缓存使用监控。系统监控方面Redis官方提供的INFO命令已经包含非常丰富的信息包括内存、连接数、命中率、持久化状态、复制状态等。把这些指标接入Prometheus和Grafana之后可以配置告警规则。甚至不用自己写prober直接用redis_exporter就能把绝大多数指标暴露出来。我建议至少监控这几个指标内存使用率、连接数、命令执行延迟、过期Key数、命中率。命中率低于80%就要排查是不是Key设计有问题或者大量请求压根没走到缓存层。容量规划方面一个常见的错误是等内存满了再想怎么办。Redis默认的maxmemory通常是物理内存总量一旦内存不够Redis会按照maxmemory-policy开始淘汰数据。如果策略是noeviction新写入直接报错线上马上就有可见故障如果是allkeys-lruRedis会随机淘汰一些Key可能导致热数据被莫名清掉。所以我建议上线前就要定好内存上限和淘汰策略并通过监控持续观察内存增长曲线提前规划扩容或者升级到Cluster模式。5. 踩坑实录与排查技巧5.1 缓存不一致最常见也最难查缓存一致性问题的排查难度主要体现在它往往是偶发的而且场景依赖很复杂。我印象最深的一次线上事故是这样的用户修改了自己的昵称页面有时候显示新昵称有时候显示旧昵称持续了大概十分钟才恢复正常。当时的代码是典型的“先删缓存再更新数据库”而且是先清Redis再发MQ去更新数据库。如果MQ消费比较慢在这个时间窗口内新的读请求发现缓存里没有数据就去数据库查查到旧值并回填到Redis。数据库更新完成之后Redis里躺着的依然是旧数据两个服务实例之间读到的内容还不一样。排查的时候是用Redis里的Key和数据库里的数据做比对发现过期的KeyTTL还很长才定位到是删除顺序导致的问题。后来我们把所有这类操作统一改成了“先更新数据库再删缓存”同时给MQ加了一条延迟兜底任务每五分钟扫描一次可疑的缓存条目。后来这类问题基本就绝迹了。这个案例想说明的是缓存一致性问题的排查不能只盯着Redis代码要把写库、消息队列、缓存刷新整条链路的时序都拉出来看。5.2 大Key和热Key上线前就要清理大Key和热Key是一对兄弟坑。大Key是指Redis里某个Key的Value特别大比如一个Hash里塞了几万条数据一个String值为几兆。它在读取时会导致单次命令执行时间非常长拖慢整个Redis实例网络传输也会占用大量带宽如果数据分布到集群里的某个分片还会造成分片间内存和流量不均匀。定位大Key不需要什么高级工具Redis自带redis-cli --bigkeys这个命令就能扫描出占用空间最大的Key。排查出来之后处理思路一般是拆分一个Hash太大就拆成多个Hash或者换用更轻量的数据结构一个String太大就看能不能拆成多个Key已经不是核心数据就清理掉。热Key是另一个让人头疼的问题某个明星突然上了热搜他的微博被几百万用户同一秒点击对应的缓存Key就被打爆了。热Key会导致Redis单分片CPU飙升、请求超时常规的加副本、扩容都很难解决。我的经验是针对这类极热数据可以在客户端加一层本地缓存把热Key缓存到应用实例内部有效分散Redis单分片的压力。当然这要考虑本地缓存与Redis之前的数据一致性问题一般用短TTL来控制接受短暂的不一致。5.3 慢查询与连接耗尽Redis的慢查询和数据库的慢查询原理很相似当你发现某个接口的P99延迟变高时可以先用SLOWLOG GET命令查看Redis侧的命令执行耗时。常见原因包括使用KEYS *做全Key遍历一次性操作数百万个Key在使用大Key进行HGETALL、SMEMBERS这类命令时返回的数据量过大批量操作时一次携带几万个参数执行MGET或LPUSH。连接耗尽问题也经常出现。Lettuce虽然是基于Netty多路复用的但在某些高并发场景下连接池依然会被打满。表现为客户端抛出RedisConnectionFailureException或者Cannot get Jedis connection如果你用的是Jedis客户端。排查思路是看Redis端的INFO clients里的connected_clients再对照应用端的连接池配置确认max-active设置是否合理。我曾经遇到过一次因为连接池max-wait设置太长导致线程大量堆积在等待连接的场景接口吞吐量直线下降最后把max-wait缩短并加大max-active才恢复。5.4 日常排障命令速查表我整理了一份日常排查分布式缓存问题时最常使用的命令和思路给运维和开发同学做个速查。场景排查手段关键指标内存快满了redis-cli INFO memoryused_memory、maxmemory、evicted_keys命中率下降redis-cli INFO statskeyspace_hits、keyspace_misses大Key定位redis-cli --bigkeys最大Key的key名和value大小慢查询定位redis-cli SLOWLOG GET 50执行耗时、命令参数热Key发现客户端采集热点统计或RedisInsight分析访问频次、分片CPUKey过期策略确认redis-cli CONFIG GET maxmemory-policy淘汰策略名称集群槽位分布redis-cli -c CLUSTER SLOTS主从映射关系哨兵切换状态redis-cli -p 26379 SENTINEL master mymastermaster地址、slaves数量这些命令不一定每天用到但线上出问题时能快速定位方向非常重要。我习惯把常用的几条命令写成一个运维脚本放到跳板机上省得每次出问题都现场翻文档。opinion_anchor分布式缓存与微服务架构的集成/opinion_anchor
返回列表