字节一面:省市区多级缓存怎么做?别上来就吹 Hash 和 ZSet 了!
写在开头一位 3 年经验的粉丝在群里复盘他的字节跳动一面。面试官抛出了一个经典的日常需求“像电商 App 里的‘省-市-区’三级地理位置接口读请求极高。如果要加缓存你会怎么设计这套多级树状数据模型”这位兄弟心想这题网上背过上来就是一顿火力全开的输出“为了防止缓存大 Key我摒弃了存大 JSON 的做法。我把所有省市区节点打散存进了 Redis 的Hash 结构里然后用ZSet来维护父子层级排序。数据变更时我用 Canal 监听 Binlog精准清理各节点的 Caffeine 本地缓存保证最终一致性……”本以为能惊艳全场结果面试官摸了摸下巴灵魂拷问了三个问题“全国省市区加起来才 3000 个节点压缩后不到 100KB。为了这点极低频变动的数据你写了复杂的 for 循环拼装还上了 MQ你不觉得这是严重的过度设计吗”“如果节点被打散新增节点时HSET和ZADD是两条命令。如果中间网络闪断导致关系链没挂上这个并发原子性你怎么保证”“对于超级热点节点你发广播让 1000 台机器同时失效本地缓存。下一秒几万个并发全部打穿到 Redis引发缓存击穿怎么防”候选人瞬间汗流浃背。其实面试大厂极容易陷入一个致命误区脱离业务体量谈架构且缺乏对并发边界的敬畏。今天我们就来拆解“多级树状数据”的缓存设计之道看看大厂老司机是怎么做架构取舍Trade-off与极致防守的。一、 场景一极小体量 极低频修改如省市区、固定商品类目典型特征节点总数 10000修改频率按月/年计。很多八股文选手一听到“大 JSON”就害怕但在这种特定的体量下“一波流的大 JSON”反而是绝对的王者。全量 JSON 本地缓存霸榜把 3000 个节点后台组装好序列化成一个完整的 JSON 树直接塞进 Redis 的 String。L1 缓存 (Caffeine)进程内直接缓存这棵树的 JSON 对象内存读取纳秒级性能无敌。L2 缓存 (Redis)作为兜底。⚠️ 防坑指南本地缓存一致性陷阱修改了行政区划绝对不能只DEL geo_tree。必须发广播通知所有 JVM 节点主动invalidate本地缓存。 但这有破绽如果你用 Redis Pub/Sub它是“发后即忘”的机器一旦 Full GC 停顿就会漏收消息。严谨解法必须设置一个合理的TTL比如 1 小时作为终极兜底。对于极度严苛的场景放弃 Pub/Sub改用Redis Stream 或 RocketMQ依靠 ACK 机制保证失效消息的必达。二、 场景二海量节点 高频动态修改如十万人企业组织架构典型特征节点数达到几十万随时有人在新增部门、修改层级。这时候如果还用“大 JSON”每次修改都会引发几十 MB 的网络 I/O 风暴。只有在这个场景“扁平化拆分”才是满分答案。拆分实体与解耦关系利用Hash集中管理所有节点属性实现 O(1) 更新利用原生集合Set / ZSet维护父子层级杜绝直接用 String 存 JSON 数组导致的并发覆盖。防坑指南原子性与缓存击穿面试官挖的两个巨坑你必须填平1. 原子性灾难新增部门时HSET和SADD绝不能分开调必须封装成一段Lua 脚本丢给 Redis 执行。利用 Redis 执行 Lua 的单线程特性保证属性写入和关系挂载“同生共死”彻底消灭孤儿数据。2. 热点击穿防守当根部门名字修改时通过 Canal 监听到变更。此时千万不能发广播让所有机器删除 Caffeine 缓存正确做法是广播消息里直接带上最新的部门名称Push 模式让所有 JVM原地覆盖更新本地缓存彻底阻断打向 Redis 的并发洪峰三、 面试通关法则抛出你的“权衡与防守”脱离业务体量谈架构都是耍流氓只谈正常逻辑不谈异常防守全是纸上谈兵。下次面试官给你挖坑直接按这个模板降维打击“对于多级树状缓存我会根据数据量级进行差异化设计Trade-off。如果是省市区这种极低频变动、总体量极小的场景我会采用‘全量大 JSON Caffeine’的极简方案。配合 MQ 广播刷新并辅以 TTL 作为防网络抖动的最终兜底。如果是大厂组织架构这种海量动态场景我会实行‘扁平化拆分’。但在落地时我一定会防死两个底线第一针对多数据结构的联动写入我会强制使用 Lua 脚本保证并发原子性第二在做本地缓存一致性同步时对于超热点节点我坚决采用‘携新值推式更新’来替代‘暴力失效’从根源上杜绝 L1 击穿导致的 Redis 雪崩。”写在最后什么叫真正的架构设计能用基础数据结构扛住千万并发并在网络抖动、宕机、高并发的极限拉扯中滴水不漏才是高级工程师的修养。这套“动态 Trade-off 思维 极权防守底线”不仅能搞定多级缓存在做任何微服务架构拆分时通通适用。