多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

企业微信API对接中的Redis缓存策略:从access_token到通讯录的实战总结

企业微信API对接中的Redis缓存策略:从access_token到通讯录的实战总结 做企业微信自建应用对接时大部分团队的第一版代码都是直连API——需要token就现取需要通讯录就现拉。等用户量上来、调用频率变高问题就开始扎堆出现token获取接口被限流、部门成员列表接口响应越来越慢、应用启动瞬间被企业微信侧拒绝服务。我接手这个项目时线上已经出现了因为access_token过期导致批量任务中断的告警这才下定决心把缓存策略系统地做起来。这篇文章把我这段时间在企业微信API对接中积累的缓存设计经验完整梳理一遍核心是围绕access_token、通讯录数据这两类高频但不常变化的数据讲清楚为什么进缓存、怎么进Redis、分布式锁怎么加、序列化坑在哪、缓存穿透击穿雪崩怎么防。适合正在对接企业微信API、或者在做类似第三方平台接口对接的Java后端开发者参考项目用的技术栈是Spring Boot 3 Redisson Redis都是目前社区里比较主流的组合。1. 先算清楚企业微信接口对接里哪些数据值得进缓存1.1 access_token的两小时之痒与获取成本企业微信的access_token是所有接口调用的通行凭证有效期7200秒也就是两个小时。按说两个小时不算短但问题在于它的获取接口有频率限制官方文档明确写了获取token的接口有每分钟调用次数配额。我在压测环境实测过连续快速请求超过配额之后接口会直接返回报错错误信息里会提示需要等待多少毫秒才能重试。这意味着如果代码里每个请求都现取token一旦并发稍微上来一点第一个被限流的就是这个接口。而且access_token是全局性的应用的所有接口调用都依赖它它一旦拿不到下游所有业务全部瘫痪。更隐蔽的成本在刷新逻辑。如果token在任务执行过程中过期比如一个批量同步任务跑了超过两小时中途要重新获取token并重试请求。这个重试逻辑写不好就会出现一批请求全部失败、然后同时抢着刷token的惊群效应。我在没做缓存前就踩过这个坑定时任务每天凌晨两点跑跑到三点token正好过期两百多个子任务几乎同时发现token失效同时发起获取token的请求直接把企业微信频率限制打爆了。1.2 通讯录数据天然的高频读、低频写企业微信的通讯录接口比如获取部门列表、获取部门成员、获取成员详情这类接口的数据有几个特点读取频率极高——前端通讯录页面、OA审批的抄送人选择器、消息推送时的接收人校验都要查但是变更频率极低——部门调整、员工入职离职一天也发生不了几次。这两类数据放进Redis缓存收益是肉眼可见的。拿获取部门成员列表来说企业微信侧接口响应时间一般在200ms到500ms之间取决于部门规模和网络状况。而Redis的读取时间稳定在1ms以内加上反序列化也不到3ms。两个数量级的差距压测时体现得非常直接。1.3 没有缓存时我遇到的真实故障我这里列一下实际发生过的故障供大家对照自检凌晨批量任务运行中出现大量token失效异常日志里出现大量获取token的并发调用企业微信侧开始拒绝请求。通讯录页面打开要转圈3秒以上每次切换部门都发出一次真实的HTTP请求后端接口QPS一高数据库也跟着承受压力。两个不同的定时任务各自维护一份内存态的token其中一个任务持有的token被另一个任务刷新后失效两个任务交替报错。如果你现在正在线上遇到类似情况不用怀疑缺的就是一层合理的缓存。2. 缓存策略的整体架构与设计取舍2.1 为什么我选了Redis而不是单纯本地缓存做缓存方案时有两个方向本地内存缓存Caffeine/Guava和分布式缓存Redis。我的结论是两者搭配但主干放在Redis。原因是本地缓存有一个绕不开的问题多实例不一致。我们后端服务部署了两台Tomcat实例如果每个实例各自缓存一份access_token就会出现我在1.3节提到的情况——A实例持有的token被其他逻辑刷新后B实例里的旧token还在用两边的token生命周期互相干扰。企业微信的token是全局唯一的同一应用同一secret在任何时刻只能有一个有效的access_token这就决定了它必须放在所有实例共享的地方。当然Redis也有单点风险所以我们的Redis本身是主从部署并开启了哨兵模式。缓存层如果挂了我后面加了一层缓存降级直连企业微信的兜底逻辑虽然慢但至少保证业务不中断。2.2 缓存设计的三层分类我把企业微信相关的缓存数据分成了三类每一类的策略都不一样第一类是access_token和jsapi_ticket这类凭证数据特点是全局唯一、过期时间固定7200秒、获取成本集中在频率限制上。这类数据的缓存策略最简单全局一份过期时间设为7000秒而非7200秒留出200秒的安全余量用于提前刷新配合分布式锁防止并发刷新。第二类是通讯录基础数据比如部门树、用户列表、用户详情。这类数据要设计缓存粒度部门树整体缓存一份单个部门成员列表按部门维度缓存成员详情可以按userId维度缓存。过期时间不用太长我设置的是30分钟基础时长加随机扰动避免同一时间大批量失效。第三类是临时性的业务数据比如调用企业微信接口后需要短暂记录的业务状态这类数据用Redis的常规键值即可过期时间按业务场景灵活设置。2.3 过期时间的推演过程这里分享一个具体的推演案例就拿access_token的缓存过期时间来说。官方有效期为7200秒理论上缓存时间设7200秒也能用但实际不行。原因有两个一是从缓存中读取到实际调用企业微信接口之间存在网络耗时和业务处理耗时如果刚好在临界点token可能刚刚过期二是企业微信侧可能存在几秒到十几秒的时钟偏差在边界情况下提前失效。具体推演过程是这样的假设缓存过期时间为T_redis官方有效期为T_official需要满足T_redis T_network T_biz T_official。其中T_network和T_biz总耗时按最坏情况估算为200秒所以T_redis上限是7000秒。这个200秒的余量足够覆盖绝大多数场景又不至于因为过早刷新导致token频繁变动。实际项目中我直接定死7000秒这个值不建议改成7200秒也不建议压得太低。3. Redis侧的实战落地从依赖配置到核心代码3.1 依赖引入和连接参数里最容易忽略的隐患项目基于Spring Boot 3.x引入Redis相关的依赖时要注意版本兼容性。Spring Boot 3默认使用Lettuce作为Redis客户端这一点和Spring Boot 2时代默认的Jedis不同。我最初的代码直接用的默认配置结果遇到了一个经典问题——Lettuce的共享连接在长时间空闲后会被服务端断开重新连接时如果恰逢高并发会出现一堆Redis command timed out异常。原因是在高并发下Lettuce重新建立连接的速度跟不上请求的到达速度。POM依赖核心部分如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.0/version /dependency注意这里的redisson依赖版本要跟Spring Boot 3匹配太老的Redisson版本会报NoSuchBeanDefinitionException。连接参数里我重点调了这几项spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 500mstimeout这个参数特别容易出问题。我同事项目里出现过在业务高峰期Redis command timed out的报错日志里还跟着一堆nested exception is io.lettuce.core.RedisCommandTimeoutException。这个异常的根因不一定是Redis真的挂了很可能是连接池满了请求在等待连接时就超时了。所以max-wait要控制在500ms以内等待超时的请求直接走降级逻辑而不是无限阻塞。3.2 自定义RedisTemplate序列化方案必须动手配置Spring Boot自动配置的RedisTemplate默认的value序列化器是JdkSerializationRedisSerializer存进Redis的是一长串二进制字节不但看着难受而且跨语言、跨版本反序列化时容易出现ClassCastException。在企业微信对接这个场景里尤其要注意因为我们需要在Redis里存储用户信息、部门列表这类有复杂结构的对象。我直接自定义了一个RedisTemplatevalue序列化用GenericJackson2JsonRedisSerializer配合ObjectMapper完成序列化。核心配置如下Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }用GenericJackson2JsonRedisSerializer解决的是通用序列化问题。它会把对象的类型信息一并存进Redis反序列化时才能还原成正确的类型。但是这里有一个隐藏的坑如果你的实体类里有LocalDateTime之类的Java 8时间类型Jackson默认处理不了必须在ObjectMapper里注册JavaTimeModule。我用的办法是自定义一个ObjectMapper传给GenericJackson2JsonRedisSerializerObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer customizedSerializer new GenericJackson2JsonRedisSerializer(om);不处理这个你的用户信息里只要带一条创建时间反序列化就直接抛异常排查起来还很隐蔽。这是我踩过的比较深的坑之一。3.3 access_token的单飞获取逻辑分布式锁的正确姿势前面反复提到access_token的全局唯一性这里直接给出我用Redisson实现的核心代码。整体逻辑是先读缓存有就直接返回没有就尝试加分布式锁拿到锁的线程去调企业微信接口并写缓存没拿到锁的线程短暂等待后递归重试再次读缓存。Service public class WeComTokenService { Autowired private RedisTemplateString, Object redisTemplate; Autowired private RedissonClient redissonClient; private static final String TOKEN_KEY wecom:access_token:default; private static final String LOCK_KEY wecom:lock:access_token; public String getAccessToken() { Object cached redisTemplate.opsForValue().get(TOKEN_KEY); if (cached ! null) { return cached.toString(); } RLock lock redissonClient.getLock(LOCK_KEY); boolean locked lock.tryLock(5, TimeUnit.SECONDS); if (!locked) { // 拿不到锁的线程说明已有线程在刷新token等待后重试 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getAccessToken(); } try { // 双重检查等锁期间可能已经有线程写好了缓存 Object again redisTemplate.opsForValue().get(TOKEN_KEY); if (again ! null) { return again.toString(); } String token fetchTokenFromWeCom(); redisTemplate.opsForValue().set(TOKEN_KEY, token, Duration.ofSeconds(7000)); return token; } finally { lock.unlock(); } } }这个逻辑里有几个细节值得展开说。第一tryLock(5, TimeUnit.SECONDS)这个等待时间选5秒是因为获取企业微信token接口的耗时一般在一秒以内5秒的等待足够覆盖一次正常的刷新流程不长不短。第二递归重试的写法看着简单但要小心递归深度我在重试前加100ms的Sleep避免拿不到锁的线程疯狂自旋。第三finally里的lock.unlock()是必须的很多人写分布式锁会漏掉它一旦线程持锁期间抛异常锁永远不会释放后续所有获取token的请求都会卡死在tryLock上。分布式锁并不是什么高深的技术但它解决的是并发场景下最常见的一个问题缓存失效瞬间的大量并发请求全部涌向后端接口。放在企业微信这个场景里后果就是token接口被限流所有依赖token的业务跟着失败。3.4 通讯录缓存读多写少的场景用读时缓存就够了通讯录数据的更新渠道有两个一是被动地通过企业微信回调事件感知变更二是定时全量同步。我的实现里两种都做了但核心链路还是定时全量同步读时回源。读时回源的逻辑是请求某个部门成员列表时先查Redis没有就调用企业微信接口拉取拉回来后写进Redis并设置过期时间。这个模式也叫Cache-Aside实现简单且在整个生命周期中都能保证数据最终一致。public ListWeComUser getDepartmentUsers(String departmentId) { String cacheKey wecom:dept:users: departmentId; Object cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return (ListWeComUser) cached; } ListWeComUser users weComClient.fetchDepartmentUsers(departmentId); long expireSeconds 1800 ThreadLocalRandom.current().nextLong(0, 300); redisTemplate.opsForValue().set(cacheKey, users, Duration.ofSeconds(expireSeconds)); return users; }这里有个反序列化的细节要注意。用GenericJackson2JsonRedisSerializer序列化List反序列化回来的是ArrayList里面每个元素默认是LinkedHashMap而不是我们想要的WeComUser对象。Spring的缓存抽象Cacheable会自动处理这个转换但我没有用Spring Cache而是直接用RedisTemplate所以必须自己处理类型转换。我的做法是不直接缓存List而是包装成一个对象public class DeptUsersCache { private ListWeComUser users; // getter/setter }序列化这个包装对象反序列化时GenericJackson2JsonRedisSerializer就能正确还原WeComUser类型。这是一个非常实用的经验写出来供大家少走弯路。4. 稳定性保障针对缓存三兄弟的工程化应对4.1 缓存击穿应用刚启动时如何防止token风暴缓存击穿的概念大家都清楚某个热点key在过期瞬间大量请求同时穿透到数据库或上游API。在企业微信对接场景里最典型的触发时机就是应用刚启动。所有业务线程启动后需要token但Redis是空的假设线程池有200个线程就会同时发起200个获取token的请求。分布式锁解决了这个问题但有一个前置条件key在Redis里真的存在时直接拿到key不存在时只能有一个线程去刷新。上面的代码已经写了双重检查这里再补充一个应用启动时的预热逻辑Component public class WeComStartupInitializer implements ApplicationRunner { Autowired private WeComTokenService tokenService; Override public void run(ApplicationArguments args) { tokenService.getAccessToken(); tokenService.getJsapiTicket(); log.info(企业微信token预热完成); } }ApplicationRunner是Spring Boot应用启动完成后自动执行的回调接口。这里的逻辑很简单就是把token和jsapiTicket在启动阶段就放进Redis避免业务流量一进来就触发并发刷新。4.2 缓存穿透恶意请求或脏数据导致的不存在的key缓存穿透指的是请求一个Redis里不存在、上游也不存在的数据每次请求都会打到企业微信接口。比如用户发起一个带任意参数userId的查询如果这个userId在企业微信里不存在我们的代码就会每次都去调企业微信接口白白消耗频率配额。解决穿透最经济的方案是缓存空值。即查询不到数据时在Redis里也存一个空对象或特殊标记过期时间设置短一些比如60秒。这样下一次相同的请求就不会再穿透到企业微信。ListWeComUser users weComClient.fetchDepartmentUsers(departmentId); if (users null || users.isEmpty()) { redisTemplate.opsForValue().set(cacheKey, Collections.emptyList(), Duration.ofSeconds(60)); return Collections.emptyList(); }这个方案有个小代价如果部门其实有成员但企业微信接口暂时返回异常缓存了空值会导致60秒内这个部门的数据都查不到。权衡下来利大于弊因为企业微信接口临时异常的概率很低而恶意请求打穿接口的概率很高。如果数据规模更大、需要更严格防护可以用Redisson内置的布隆过滤器。通讯录场景里用户规模到几万甚至几十万时布隆过滤器能快速判断一个userId是否存在把大部分不存在的请求拦截在最前面RBloomFilterString bloomFilter redissonClient.getBloomFilter(wecom:bloom:userIds); bloomFilter.tryInit(100000L, 0.01); // 初始化时把存量userId全部放入: bloomFilter.add(userId) // 查询前先判断: boolean mightExist bloomFilter.contains(userId)布隆过滤器有极小的误判率但绝不漏判很适合判断不存在就快速拦截的场景。注意tryInit只应该在启动时调用一次重复初始化会覆盖已有数据。4.3 缓存雪崩过期时间加随机扰动后的实测效果缓存雪崩是指大量key在同一时间过期导致这一瞬间请求全部回源。我们通讯录相关缓存数量不少部门成员列表、用户详情、部门树加起来上千个key。如果大家都用同一个固定过期时间那么每到整点或半点就会出现一波回源压力。解决办法很简单就是给过期时间加随机扰动。我设置的是基础1800秒加上0到300秒的随机值让过期时间分布在1800到2100秒之间。这样原本可能集中失效的key会被打散。实测下来回源请求量的峰值明显平坦不再是原来的尖峰状。这个技巧同样适用于token类缓存。虽然access_token只有一个key不存在雪崩问题但像jsapi_ticket这类多应用、多secret的场景每个应用的key加一点随机扰动也能避免同时刷新。5. 实测中踩过的坑七条经验供直接对照5.1 序列化导致的ClassCastException排查经历这是我印象最深的一个坑。上线当天环境一切正常但是用户详情接口偶发报错堆栈指向java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.xxx.WeComUser。排查链路是这样的先看报错位置发现在从Redis取用户详情后做类型强转时崩了。继续追发现这个用户详情在写入Redis时业务代码传的是一个Map而读取时我们期待的是WeComUser对象。Spring Cache会用类型信息辅助反序列化但直接用RedisTemplate时如果写入时的类型和读取时的类型不一致就会出现这个问题。修复方案就是前面说的包装对象直接缓存带完整类型信息的对象而且在写读两端的类型保持一致不要一个用DTO一个用实体类。5.2 Lettuce连接超时问题从报错到定位Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这条报错我们排查了很久。一开始以为Redis性能不行看监控CPU、内存、慢查询日志都没有问题。后来翻到Lettuce的源码和文档才知道Lettuce是单连接多路复用的默认的连接池上限是1。一旦某个业务操作比如一个大key的批量读取阻塞了这条连接其他所有请求都得排队等。解决方法是显式配置Lettuce连接池并把max-active调到合理值。这里要注意不是越大越好。max-active16在大部分场景下都够用了因为单连接本身能承载很高的吞吐量连接池只是给阻塞操作留出冗余。5.3 缓存与数据库的一致性先更缓存还是先调接口这个问题在企业微信对接场景里有特殊之处。我们的上游是企业微信而不是自己的数据库数据更新时机不掌握在自己手里。比如员工关注了应用的某个入口企业微信后台会推送变更事件到我们的回调接口这时要主动把相关缓存删掉或刷新。我的策略是回调事件驱动删除缓存定时任务全量刷新兜底。这样既保证了变更的即时性又避免因为回调消息丢失导致数据长期不一致。具体做法是收到回调事件后解析出变更的部门ID执行一条redisTemplate.delete(wecom:dept: departmentId)下次请求自然回源拉取最新数据。5.4 使用Redisson分布式锁时的看门狗机制Redisson的锁默认有个看门狗机制会在锁未释放且业务还在执行时自动续期。这个机制一般是有用的但在我们获取access_token这个场景里如果业务卡住比如企业微信接口长时间不返回锁会一直续期其他线程会一直等待。虽然tryLock设置了超时时间但续期机制会让这个超时形同虚设。所以要控制好持锁的时间把拿锁—调企业微信—写缓存—释放锁这个过程尽量收敛。如果真的担心接口卡死可以通过配置关掉看门狗续期lock.lock(10, TimeUnit.SECONDS)此时Redisson不会续期超过10秒自动释放锁。5.5 缓存key的规范命名企业微信对接涉及的Redis key种类不算多但如果不规范时间长了会乱。我统一用的是wecom:{业务模块}:{主键}的格式比如wecom:access_token:defaultwecom:jsapi_ticket:defaultwecom:dept:users:{deptId}wecom:dept:treewecom:user:{userId}wecom:bloom:userIds这种命名方式能很方便地用SCAN wecom:*去查看所有相关缓存排查问题时一眼能看出归属。5.6 大key导致的阻塞用户列表不能整体缓存获取部门成员列表如果这个部门有上万人单个Redis value可能超过1MB这会成为大key。Redis是单线程模型读取大key时会导致其他请求排队等待造成秒级延迟。我的经验是缓存粒度一定要控制好一个部门的成员列表超过500人就应该考虑分页缓存或只缓存基础摘要信息。5.7 缓存降级兜底逻辑缓存不可能是100%可用的。我和团队在WeComTokenService里加了降级开关如果Redis不可用直接用内置的内存Map暂存token标记降级模式并告警。代码逻辑简单但非常必要因为Redis故障时如果接口全部直连企业微信频率限制崩溃得更快。降级之后至少保障了核心业务流程不中断等Redis恢复了再切回正常链路。6. 上线后的监控与容量规划经验6.1 关键监控项和告警阈值缓存和Redis的监控不能只盯着Redis进程还活着这一点要细化到业务层面。我最终沉淀下来的监控指标有这几项Redis内存使用率和Key数量超过总量70%时告警主动检查是否有大key或者无效key堆积。缓存命中率我按业务维度统计。企业微信场景里access_token的命中率应该在99.99%以上通讯录数据的命中率应该在90%以上。哪个值掉下去说明过期时间或缓存策略有问题。分布式锁等待耗时如果tryLock经常等待超过1秒说明锁竞争激烈要回头排查是否有大key或慢操作持锁过久。降级触发次数一旦降级开关被触发直接P0告警说明Redis已经出现实质性问题了。企业微信侧返回的频率限制错误码这个指标重要到可以单独出一个面板一旦出现频率限制相关的错误码就要核对是不是缓存策略失效导致回源量突增。6.2 压测结论和容量估算我做过一轮基于实际业务的压测。模拟的是200个并发用户同时打开通讯录页面每个页面请求部门树、成员列表、成员详情三个接口持续压测10分钟。没有缓存时后端对企业微信接口的调用量大约为每分钟12000次直接触发企业微信限流。加了缓存后真实打到企业微信接口的请求量下降了超过90%因为大部分用户的部门树和成员列表在Redis里直接命中。最终每分钟回源量只有不到800次压测期间没有再出现限流报错。基于这个数据我给团队的容量建议是企业微信对接服务的Redis只需要一个主从架构的小规格实例就够了内存预留在1GB以内因为存的是结构化数据而不是大文件。真正要关注的不是内存而是连接数和回源频率。连接数直接由应用实例数和连接池配置决定回源频率则由缓存命中率决定。6.3 后续可扩展的方向这套缓存策略目前已经被同组另一个第三方接口对接项目复用了只需要把wecom前缀换成对应平台的标识。如果再深入做下去有两个方向值得探索一是用Redis Stream或发布订阅来处理企业微信回调事件的异步分发替代现在的轮询兜底二是把缓存管理和业务代码抽离成独立的Starter做成通用基础组件。不过这些都是后话当前版本的核心价值已经足够解决企业微信API对接中的缓存策略这个具体问题。如果你正在做的事情跟我类似建议先不要急着引入复杂的缓存框架而是把最关键的几个点落实到位token必须全局共享、通讯录数据按粒度拆开缓存、序列化方案统一、分布式锁不能省略、降级兜底必须有。把这些做扎实企业微信对接的稳定性会上一个台阶。
返回列表