缓存组件设计复盘从硬编码到带模式参数的缓存进化电子面单对接实录 · 技术深潜 前情《Token刷新与缓存失效一对被忽视的上下游》新架构里有一个缓存组件专门缓存各平台的Token配置避免每次取号都查数据库。最初的设计很简单平台编码加组织ID做Key查不到就查库查到就缓存。跑了一段时间后发现一个隐患同一个平台的不同业务模式缓存会互相覆盖。这篇文章完整复盘这个缓存组件的设计演进过程——从最初的两层缓存结构到新增六个方法、提取公共查询逻辑、处理JDK版本兼容以及最重要的给缓存Key加一个模式后缀。01 初始设计两层缓存按平台加组织路由缓存组件的最初版本结构清晰两层缓存平台应用配置缓存Key 平台编码存平台基础配置平台Token配置缓存Key 平台编码 分隔符 组织ID存Token的数据库记录查询逻辑先从缓存取缓存未命中就查数据库查到后放入缓存。支持手动清除指定缓存支持定时全量刷新。平台差异处理大部分平台通过通用Token配置表查Token但有一个社交电商平台的Token存在专门的授权表里需要特殊处理——直接跳过通用查询走白名单通道。这个设计跑了一段时间没问题。直到有一天我在梳理Token刷新逻辑时发现了一个隐患。02 问题发现两种业务模式在抢同一个缓存Key系统里有一个短视频电商平台它有两种业务模式普通商家模式和代发模式。两种模式用同一个平台编码但Token存在不同的字段——普通Token和代发Token是两套独立的凭证。问题来了缓存Key是平台编码_组织ID两种模式的Key完全一样。普通取号缓存Key PLAT_A_123 → 查到的是普通Token 代发取号缓存Key PLAT_A_123 → 缓存命中返回的还是普通Token应该是代发Token先刷新普通Token再刷代发Token或者反过来缓存会被互相覆盖。当前没出问题是因为两种模式的刷新在同一个定时任务里几乎同时更新缓存清空后重新加载的值恰好是正确的。但未来如果把普通和代发的刷新任务分离比如普通每小时刷一次代发每两小时刷一次就会出现缓存加载到过期Token的情况。03 解决方案给缓存Key加一个模式后缀最简单的方案在缓存Key中增加业务模式维度。// 原Key平台编码 _ 组织IDStringkeyplatCode_orgId;// 新Key平台编码 _ 组织ID _ 业务模式StringkeyplatCode_orgId;if(bizMode!null!bizMode.isEmpty()){keykey_bizMode;}不同模式存在不同的Key下互不干扰普通取号缓存Key PLAT_A_123_normal → 缓存未命中 → 查库 → 拿到普通Token 代发取号缓存Key PLAT_A_123_daifa → 缓存未命中 → 查库 → 拿到代发Token设计约束bizMode为null或空字符串时退化为原有Key格式。完全向后兼容——现有调用方不需要任何修改只有需要区分模式的场景才传入bizMode。04 方法扩展新增六个方法覆盖所有场景加了一个参数就需要配套的查询和清除方法。最终新增了六个方法方法用途getTokenConfig(platCode, org, bizMode)带模式参数的查询evictToken(platCode, org, bizMode)带模式参数的单个清除evictTokenById(platCode, orgId, bizMode)带模式参数的按ID清除evictByPlatform(platCode, bizMode)带模式参数的按平台批量清除evictTokenById(platCode, orgId)按组织ID清除不需要完整对象evictByPlatform(platCode)按平台批量清除无模式参数按组织ID清除原本的清除方法需要传入完整的组织对象。但有些调用场景比如外部监控系统检测到Token过期只知道组织ID没有完整对象。新增一个重载方法只传ID即可。按平台批量清除原本只能单个清除或全部清除。如果某个平台的AppKey全局变更需要失效该平台下所有组织的缓存只能遍历组织逐个清除。新增按平台批量清除一次操作搞定。带模式参数的批量清除更进一步——如果只是代发模式的AppKey变更只清除代发模式的缓存不影响普通模式。批量清除时按模式后缀匹配。05 JDK兼容removeIf不能用批量清除的核心逻辑是遍历缓存Map删除匹配前缀和后缀的Key。Java 8 有现成的方法// Java 8简洁优雅cache.keySet().removeIf(key-key.startsWith(prefix));但项目的运行环境是旧版本JDK不支持removeIf。只能改为迭代器模式// 旧版本JDK兼容迭代器遍历IteratorStringitcache.keySet().iterator();while(it.hasNext()){Stringkeyit.next();if(key.startsWith(prefix)){it.remove();}}带模式参数的批量清除需要在匹配前缀的基础上再加后缀判断// 匹配以 PLAT_A_ 开头、以 _daifa 结尾的KeyIteratorStringitcache.keySet().iterator();while(it.hasNext()){Stringkeyit.next();if(key.startsWith(prefix)key.endsWith(suffix)){it.remove();}}代码量多了几行但逻辑完全一致。遗留系统的技术栈约束就是这样——新版本JDK的流式API、Lambda表达式都用不了设计方案时必须考虑向下兼容。06 代码重构提取公共查询方法加带模式参数的查询方法时发现整个数据库查询逻辑在两个重载方法中完全重复——包括白名单判断、通用表查询、各平台分支的判断。提取一个公共方法loadTokenFromDBprivateOrgTokenConfigloadTokenFromDB(StringplatCode,OrgInfoorg){// 白名单社交电商平台直接查授权表if(isWhiteListPlatform(platCode)){returnqueryFromAuthTable();}// 其他平台查通用Token表TokenRecordrecordqueryFromTokenTable(org.getId());if(recordnull){returnnull;}// 根据平台编码获取对应的配置对象if(isPlatformA(platCode)){returnrecord.getConfigA();}elseif(isPlatformB(platCode)){returnrecord.getConfigB();}// ... 其他平台分支}关键细节每个分支保留了原有的日志输出包括平台中文名。这确保了回归测试时日志一字不差不会因为日志格式变化触发监控告警。重构后两个查询方法只剩缓存Key构建方式的差异——一个带模式后缀一个不带。数据库查询完全委托给公共方法。07 向后兼容旧代码一行不动所有新增方法都是可选的扩展原有方法的行为完全不变原有查询方法两个参数行为不变原有清除方法按对象清除行为不变原有全部清除方法行为不变新方法是额外的工具旧代码不需要任何修改。当需要区分模式时调用方传入bizMode参数即可。回归测试验证通过所有平台分支的日志输出与原有代码完全一致。08 核心收获1. 缓存Key的粒度设计在系统演进中会被重新审视。最初设计时只有普通模式代发是后来加的。一个后缀解决两个模式抢Key的问题改动量很小——但前提是Key的构建逻辑是收敛在一处的而不是散落在各调用方。2. 公共方法提取的价值不在当下在未来。提取数据库查询公共方法后后续新增平台只需要在判断分支中加一个条件两个查询方法自动生效。如果没提取新增平台要改两个地方忘了一个就是Bug。3. 向后兼容是遗留系统改造的底线。bizMode为 null 时退化为原有行为——这行判断保证了新老代码可以并存。新增方法、提取公共逻辑都不影响已有调用方。4. 技术栈约束影响设计决策。removeIf一行代码能解决的问题在旧版本JDK上要多写四行。这不是设计缺陷是工程现实。讨论话题你做缓存设计时遇到过Key粒度不够导致数据互相覆盖的情况吗是加后缀还是重新设计了Key结构评论区聊聊。