
去年年中有一次版本上线之后我所在的移动端团队连续接了三天线上报警。首页接口的P95耗时从700ms一路涨到2.3秒数据库连接池反复打满值班手机凌晨两点还在响。当时第一反应是“是不是新功能写坏了 SQL”可查了一圈之后发现问题比想象中更隐蔽——整条首页链路从网络请求到数据库查询再到数据聚合几乎没有任何一层在做缓存。每一个用户滑动首页后端都要现查数据库现算推荐位现拼聚合数据。在日活几十万的量级下这种“每次请求都重新造一遍轮子”的架构迟早会被流量压垮。当时我做的第一件事就是把整条首页加载链路完整拆了一遍目标是搞清楚时间到底花在了哪里。这篇文章就是那次缓存体系改造的完整复盘客户端缓存怎么分层、数据库侧怎么减负、缓存一致性和三个经典异常怎么处理以及上线后遇到的四个真实坑。文章适合正在做移动端 App 性能优化、首页频繁改版、或者被“接口调用慢/数据库压力大”困扰的团队参考。不涉及具体业务代码细节的地方我会补上通用的设计方案和可复现的排查思路尽量做到照着就能落地的程度。1. 首页为什么慢一次真实请求的耗时拆解与优化突破口1.1 一次首页请求的时间账单在动手改任何代码之前我先在测试环境里抓了一次完整的首页请求链路。用 Charles 抓包配合服务端链路追踪日志把一次典型的首页刷新拆成了五段客户端网络建连、DNS 解析、服务端接口处理、数据库查询、数据反序列化与渲染。那次实测的数据大致是这样环节耗时占比说明DNS TCP/TLS 建连80ms11%首次请求无连接复用弱网下会更严重服务端接口处理420ms56%其中包含 4 次上游 RPC 调用数据库查询250ms33%首页推荐位、用户信息、配置表多次重复查询数据传输与 JSON 解析60ms8%Go 端 JSON 序列化 客户端反序列化客户端渲染40ms5%列表 diff 与图片占位处理从这里能看得很清楚真正的瓶颈不在网络而在服务端接口处理和数据库查询这两块合计占了接近九成耗时。而这两块恰恰是最适合用缓存机制去优化的位置。首页聚合数据是“读多写少”的典型场景——用户刷新频率远高于后台配置的修改频率如果每个用户每次刷新都触发一次全量数据库查询那就是纯浪费。1.2 为什么“加一台数据库”不是正确答案团队里有人提出最简单粗暴的方案数据库加只读从库把查询压力分摊到多台机器上。这个方案能解决一部分连接数问题但治标不治本。原因有三个。第一首页接口是典型的聚合接口一次返回可能要拼接用户信息、运营配置、推荐列表、公告等多块数据。哪怕数据库查询本身变快了服务端还是要把这些数据逐条查询、组装、序列化再走一遍网络传输。对客户端来说响应体大、序列化时间长的问题依然存在。第二移动端场景和纯 Web 后端不一样弱网、弱机、信号切换都是常态。后端再快客户端网络抖动一样会让首页白屏。光在服务端优化数据库解决不了“地铁里刷不出首页”的用户投诉。第三数据库从库扩容成本高而缓存机制的收益远大于扩容一次缓存命中可能直接把一次 250ms 的数据库查询变成 1ms 内的内存读取。这是数量级的差距加机器很难做到。所以最终我们定下的优化方向是“客户端缓存降网络 服务端缓存降数据库”双管齐下而不是单纯堆数据库资源。这也对应了我们最早定下的三个目标首页冷启动首屏时间控制在 2 秒内、接口 P95 降到 1 秒内、数据库 QPS 至少下降 60%。1.3 一个容易被忽略的索引冷启动、热启动、回访移动端首页的缓存设计必须先区分用户访问的三种场景因为它们的优化策略完全不同冷启动进程被杀后重新打开 App内存缓存全部丢失只能依赖磁盘缓存和网络请求热启动App 切后台再切回来进程还在内存缓存可能仍然有效体验最好回访用户退出后隔几小时或隔天再打开内存缓存大概率失效但磁盘缓存还能兜底。我们的优化重点是冷启动和回访这两种。因为热启动本来就很快而用户投诉“首页慢”绝大多数发生在冷启动和网络切换场景。后面第三节要讲的磁盘缓存、HTTP 缓存本质上都是为这两种场景服务的。2. 客户端三层缓存布局内存、磁盘与 HTTP 缓存各管一段2.1 第一层内存缓存负责“毫秒级闪开”内存缓存是整个缓存体系中最快的一层读取耗时一般小于 1ms。移动端最常用的实现是 Android 的LruCache和 iOS 的NSCache两者都基于 LRU 策略最近最少使用的内容优先被淘汰避免内存无限膨胀。但我想提醒的是不要只盯着 LRU 算法TTL 才是内存缓存最需要设计的参数。首页数据里有用户强时效性数据比如未读消息数也有弱时效数据比如运营 banner还有几乎不变的数据比如 App 版本对应的功能开关。如果统一用一个 TTL要么过度刷新造成浪费要么数据过期时间不一致让用户看到“半新半旧”的页面。我们当时的做法是给缓存条目打标签分三档时间数据类型TTL 参考场景举例强时效30~60 秒未读消息数、用户积分中时效5~15 分钟首页推荐位、评论区热门弱时效数小时至一天版本配置、隐私协议、城市列表这样用户每次冷启动App 首屏可以先按“弱时效→中时效→强时效”的顺序读缓存把首页框架立刻画出来再在后台异步拉取最新数据更新。这就是“秒开”体验的核心思路。2.2 第二层磁盘缓存负责“冷启动兜底”内存缓存再好进程一死就全没了所以磁盘缓存是冷启动场景的命根子。我们用的是磁盘 LRU 方案Android 端直接用了DiskLruCache的思路iOS 端则自己实现了一套基于文件目录 最近访问时间清理的缓存管理器。磁盘缓存的关键不是算法而是文件格式和序列化方案。很多团队把整个 JSON 字符串直接存进一个文件里看起来简单实际上有几个问题JSON 解析耗时高大对象容易卡主线程磁盘文件没有校验很容易因为写入一半断电导致读取崩溃明文缓存还有隐私风险。我们的实践是把响应体按“业务模块”拆开存每个模块一个缓存条目序列化用 Protocol Buffers 或者压缩 json 二选一。拆开存的好处是如果只有推荐位模块过期就只刷新推荐位其他模块继续用磁盘缓存省流量也省时间。隐私方面涉及用户 ID、手机号等敏感字段的模块直接不入磁盘缓存只进内存且加密。磁盘缓存的写入一定要放在子线程。如果用主线程同步写文件一次 I/O 就可能卡掉 20~30ms这对首页首帧渲染来说是不可接受的。2.3 第三层HTTP 缓存负责“和服务端对表”前两层是客户端本地缓存第三层是客户端与服务端之间的协议级缓存。很多移动端团队会忽略这一层觉得“服务端已经返回 JSON 了还要什么缓存”但实际上 HTTP 缓存能帮你省掉一整轮网络请求。常规做法是服务端在响应头上返回Cache-Control和ETag。客户端下一次请求可以带上If-None-Match如果数据没变服务端直接返回 304 Not Modified连响应体都不用下传。这个机制在首页接口上效果极好因为我们 80% 的首页请求数据其实没有任何变化。我们用这套方案后首页接口的重复请求率从 70% 降到了 20% 左右。但要注意HTTP 缓存只适合 GET 请求而且不能用于需要严格实时性的数据。如果是用户余额、订单状态这类信息必须跳过 HTTP 缓存直接回源。2.4 三级联动的读取顺序内存 → 磁盘 → 网络回源把三层缓存串起来的读取逻辑并不复杂但写的时候很容易漏掉一些边界情况。我们的简化版实现思路是这样// 伪代码首页数据读取流程 fun loadHomeData() { val cached memoryCache.get(KEY_HOME) // 第一步读内存 ?: diskCache.get(KEY_HOME) // 第二步内存没有读磁盘 ?: networkClient.fetch(KEY_HOME) // 第三步磁盘也没有走网络 if (cached ! null) { renderHome(cached) } // 网络请求不管有没有命中本地缓存都尽量发用于刷新数据 refreshAsync() }注意这里有个细节即使本地缓存命中了我们也会在后台异步发起一次网络请求目的是刷新缓存数据。这样用户打开首页立刻能看到旧数据不需要等网络过一两秒数据更新了再去增量替换页面。这是“秒开 最新数据兼容”的标准做法也是后面第四节缓存一致性要处理的核心矛盾。3. 数据库侧减负查询热点识别与结果复用策略3.1 先别急着上 Redis把慢 SQL 找出来再说我见过太多团队一提“数据库性能优化”就条件反射地搭一套 Redis然后把所有查询结果往里塞。缓存不是万能药它是给“热点查询”和“重复查询”准备的如果数据库本身的慢 SQL 没解决就算塞了缓存也只是把慢查询藏到了缓存后面一旦缓存失效DB 该崩还是崩。我们的第一步是打开数据库慢查询日志把过去一周执行时间超过 200ms 的 SQL 全部拉出来按“执行次数 × 平均耗时”排序找出前十名。结果很有意思榜首是一条反复查最新公告的 SQL单次执行 380ms每天执行了 40 多万次因为每个用户的首页都要查一遍公告而这个公告内容其实一周才更新一次。这属于典型的“重复查询”缓存收益巨大。第二名的 SQL 是首页推荐位列表每天执行 30 万次单次 260ms。这个更适合做“预聚合缓存”因为推荐位的排序规则复杂SQL 里带了好几个 JOIN 和子查询每次现算都很贵。这两条 SQL 占了全库查询量的 22%把它们优化掉之后数据库整体 QPS 差不多直接降了三成。所以我的建议是优化数据库性能的第一步永远是找热点而不是上缓存组件。3.2 查询结果缓存Cache Aside Pattern 的正确姿势找到热点 SQL 之后缓存的数据结构也要想清楚。对“查询结果”这种场景业内最通用的模式是 Cache Aside旁路缓存核心流程一句话读的时候先读缓存缓存没有就读数据库然后把结果写进缓存写的时候先更新数据库再删除旧缓存。这里有一个当年让我反复踩坑的问题更新数据库和删除缓存之间到底哪些地方会出问题举个具体例子用户首页的公告缓存 key 是notice:latest后台运营发了一条新公告服务端执行了UPDATE notice SET ...紧接着执行了DEL notice:latest。正常情况下缓存删除后下一个请求会回源数据库读到新公告再写回缓存一切正常。但如果删除缓存那一下 Redis 超时了旧的公告还留在缓存里可能要在 TTL 过期后才能被替换。最坏情况是用户多看了几个小时旧公告。当时我们引入了一个很实用的补救措施——延迟双删先删除缓存更新数据库隔 500ms~1s 再删一次缓存。这样做是为了解决“另一个请求在第二步和第三步之间把旧数据重新写回缓存”的并发问题。延迟双删不是银弹但复杂度低能在大多数业务场景下把不一致窗口压缩到极小。3.3 首页聚合数据直接缓存“组装好的 JSON”省掉重复计算除了单条查询结果首页这种聚合接口还有一个更激进的优化思路把服务端组装好的整个首页响应体直接缓存起来。也就是说用户信息、推荐位、公告、配置这些数据服务端先查齐、组装成最终的 JSON然后以用户维度或用户分组维度写进 Rediskey 类似home:v2:userIdvalue 是完整响应体TTL 设置为 60 秒。这个方案的效果立竿见影数据库查询全部省掉RPC 调用也省了服务端接口只需要从 Redis 读一次字符串再返回单次接口耗时从 420ms 降到 30ms 左右P95 从 2.3s 降到 80ms。但这个方案也要付出代价——缓存粒度越粗一致性越难控制。用户数据一改整个缓存都要失效运营改一条推荐位所有人的首页缓存都要清掉。我们当时的妥协方案是把首页拆成 4~5 个区块每个区块单独缓存单独 TTL服务端返回时把多个区块 JSON 片段拼起来。这样“推荐位”变了只需要让推荐位区块失效用户信息区块还能继续命中缓存。粒度调优是个反复试错的过程但收益非常直接非常值得做。3.4 连接池保护别让缓存把数据库饿死最后分享一个容易忽视的细节。加了缓存之后数据库的查询量大幅下降但连接池的配置反而需要重新调整。因为回源请求变少了连接池里大部分连接处于空闲状态如果还保持原来的 100 个连接会造成资源浪费但如果把连接池调得太小一旦缓存大面积失效回源流量会瞬间打满连接池造成连环故障。我们的做法是保留一个“安全余量”把连接池从 100 调到 50但同时给“首页回源”单独设置了熔断阈值——当缓存命中率低于 80% 时自动把首页接口切回旧数据的“降级版”优先保住客户端能出页面而不是让数据库崩溃。4. 缓存失效与一致性比缓存本身更值得花时间的设计4.1 四种失效策略的对比与选型缓存一致性问题的本质是数据在数据库和缓存之间存在时间差。没有失效策略的缓存数据永远是旧的失效太勤缓存又失去意义。我们内部对比过四种失效策略失效策略原理适用场景缺点TTL 定时过期缓存写入时指定存活时间大部分读多写少场景时效性不够精准主动删除数据更新时主动删除对应缓存强一致性要求场景依赖业务代码埋点版本号缓存 key 中加入版本号版本号变化则缓存失效运营配置类数据版本号需要全局维护事件驱动数据库变更通过 binlog/MQ 通知缓存层大规模分布式系统链路长、复杂度高对移动端首页这种场景我们的组合是“TTL 主动删除”。具体来说运营后台只要修改了配置就发一条消息服务端收到消息后删除对应缓存 key如果消息丢失TTL 兜底保证缓存最终过期。双保险比单纯依赖任何一种都稳。4.2 先更新数据库还是先删除缓存这是缓存设计里的经典问题。网上有无数讨论我直接给出结论绝对不要“先删缓存再更新数据库”。因为更新数据库期间如果来了一个请求发现缓存没有就会去数据库读旧数据再写回缓存于是缓存里又变成了旧值且这个旧值会一直存活到 TTL 过期。正确顺序是“先更新数据库再删除缓存”。这个顺序也有并发窗口但窗口极小配合延迟双删基本可以接受。当年压测时我们用了一个很凑巧的复现方式更新公告接口同一个 key 并发来了 100 个读取请求。在“先删缓存再更新 DB”的旧逻辑下几乎所有请求都打到了数据库而且返回结果一半旧一半新。后来改成“先更新 DB 再删缓存”并加了延迟双删同样压测只有极少数请求会命中旧缓存业务层面完全可以接受。4.3 数据库变更如何通知缓存层binlog 订阅方案如果团队业务复杂靠业务代码到处手动删缓存很容易漏。我们的服务端在运营后台和用户中心两个系统里尝试了事件驱动用 Canal 订阅数据库 binlog把变更事件推到 MQ消费端根据变更表名和主键拼出缓存 key自动删除对应缓存。这个方案的优点是业务代码零入侵——不需要在 UPDATE 语句后面手动加 DEL 命令缺点是链路长Canal 挂了会影响缓存更新时效。我们当时的落地策略是分阶段先只在“公告表”和“推荐位表”这两张高频变更且强一致要求的表上启用 binlog 订阅其余表继续用显式删除 TTL 兜底。跑了两周稳定性足够才逐步扩大到更多表。4.4 移动端场景的一致性妥协最终一致把时间差控制在可接受范围移动端和服务端最大的不同在于用户手机上的数据不管服务端怎么推都有天然的“滞后”。所以移动端缓存设计不应该追求强一致而是追求“最终一致 可感知的刷新时机”。我们的首页策略是用户冷启动后先展示本地磁盘缓存同时触发服务端刷新服务端返回新数据后客户端做增量 diff 更新。用户看到的现象是“页面秒开大约半秒后数字跳动变成最新”。这个体验用户普遍能接受反而比白屏等 2 秒更舒服。要留意的是增量更新不能做得太激进。我们曾经尝试每次刷新都全量对比列表结果低端机上列表 diff 耗时 200ms卡顿明显。后来改成只在服务端响应 header 里放一个dataVersion客户端比对版本号版本变了才做 diff版本不变就完全不动 UI。5. 防穿透、防击穿、防雪崩三个必须提前铺好的底线5.1 缓存穿透查询了一个永远不存在的数据缓存穿透指的是请求的数据在数据库和缓存中都不存在每次请求都直接打到数据库。移动端最常见的情况是用伪造的用户 ID、越权的商品 ID 去请求或者用户刚删除了某条内容但客户端还在用旧 ID 请求。如果没有防护这类请求可以绕过缓存直接压垮数据库。防护手段有两种我们都在用缓存空值查询数据库发现结果为空也把这个空结果写入缓存TTL 设短一点比如 30~60 秒。后续同样的请求直接命中空缓存不会打到数据库。布隆过滤器在缓存和数据库之间加一道过滤器请求的 key 如果不在过滤器中直接返回默认值。布隆过滤器的优势是内存占用极小几百万个 key 只需要几十 MB缺点是存在小概率误判但误判方向是“存在可能查不到”对业务影响很小。5.2 缓存击穿热点 key 在过期瞬间被打爆缓存击穿和穿透一字之差但机制完全不同击穿指的是一个热点 key 正好在过期时间被大量请求同时访问所有请求都发现缓存没有瞬间全部回源数据库数据库直接被打挂。移动端首页的“公告配置”“全局开关”这类 key 就是典型的热点 key——所有用户的首页都会读它。我们的做法是给回源过程加互斥锁Redis 没有命中时先尝试获取一把分布式锁只有拿到锁的请求才允许去数据库查询并回填缓存其他请求短暂 sleep 后重新读缓存。代码思路如下# 伪代码缓存击穿防护 value redis.get(key) if value is None: if redis.setnx(lock_key, 1, ex5): # 尝试加锁 value db.query(...) redis.set(key, value, ex600) redis.delete(lock_key) # 释放锁 else: time.sleep(0.05) value redis.get(key) # 其他人等锁释放后重试这个方案会稍微增加单次首访延迟但保证了数据库同时最多只有一个热点查询在跑收益远大于代价。需要注意 lock_key 必须设置过期时间否则某个请求拿锁后进程崩溃锁永远不会释放会造成死锁。5.3 缓存雪崩大量 key 同时过期雪崩是击穿的放大版大量缓存 key 在同一时间段过期导致大量请求同时回源数据库。最常见的原因是统一把 TTL 设置成一样的时间比如所有 key 都是 10 分钟那么每 10 分钟就有一次回源高峰。解决方法非常朴实给 TTL 加随机扰动。比如基础 TTL 设为 600 秒实际设置时加 0~300 秒随机值。这样 key 的过期时间自然散开不会出现“准时打群架”的场景。另外多级缓存也会在雪崩时起保护作用——客户端本地还有内存和磁盘缓存服务端 Redis 挂了客户端也不至于白屏。5.4 给首页场景的配置参考结合上面的原理我当时给团队产出了一份可直接用的配置清单场景key 示例TTL防护手段公告/运营配置config:notice300~600s 随机 60s主动删除 互斥锁首页聚合碎片home:block:${blockId}60s 随机 10s延迟双删 兜底降级用户信息user:${userId}30s主动删除 空值缓存缓存穿透黑名单blocklist:${uid}不设 TTL布隆过滤器这套配置不是固定的要根据业务访问模型调。但核心思想是一致的热点 key 必须有击穿保护所有 key 的过期时间必须随机化空结果也必须被缓存。把这三点做到三个经典故障就已经挡住了一大半。6. 改造后的实测数据与踩过的四个坑6.1 改造前后数据对比整个改造持续了三周分三批上线第一批客户端内存和磁盘缓存第二批服务端聚合缓存和热点查询缓存第三批布隆过滤器和延迟双删。全部上完一周后我们拉了监控数据指标改造前改造后变化首页冷启动首屏时间3.8s1.4s下降 63%首页接口 P952.3s80ms下降 96%首页接口 P994.1s320ms下降 92%数据库整体 QPS120004100下降 66%DB 连接池使用率96%报警31%恢复正常用户“首页加载失败”反馈日均 30日均 3明显改善最直观的感受是之前随手打开首页总要先转圈半秒到一秒改造完之后基本是“点到即出”这一点连产品经理和测试同事都主动来问做了什么。6.2 坑一缓存存大对象GC 和网络双双受伤我们最早把整个首页 JSON 作为一个 value 存进了 Redis一个 value 就有 60KB。一开始没觉得有什么问题等并发上来后发现两个现象服务端 Go 进程的 GC 明显变频繁因为每次从 Redis 反序列化 60KB JSON 对堆内存压力不小同时客户端弱网环境下下载 60KB 也比预想中慢。后来我们做了两个改变一是把首页拆成多个区块缓存每个区块控制在 10KB 以内二是把 JSON 换成更紧凑的序列化格式。这个教训就是缓存设计的性能瓶颈往往不在存储而在序列化和网络传输。大对象缓存要慎重能拆则拆。6.3 坑二热 key 问题单 key 扛了 90% 流量上线后我们发现 Redis 有一个 key 的访问量占了总访问量的九成——就是全局公告配置那个 key。虽然 Redis 单 key 抗压能力强但持续高并发访问同一个 key 会有两个隐患一是 Redis 单分片 CPU 会飙高二是如果这个 key 一旦失效击穿效应会非常猛。我们最终的解法是“key 分片”把同一个数据复制成 16 份分别存储为notice:v1:1、notice:v1:2一直到notice:v1:16读取时按用户 ID hash 到某一个分片。这样单 key 的并发量直接降到原来的 1/16。代价是数据更新时要写 16 份但对低频更新的配置数据来说完全值得。6.4 坑三缓存空值导致“僵尸数据”僵了好几天有一段时间我们遇到一个诡异的问题某条新闻已经下架三天了但部分用户首页还能看到。排查了很久最后发现是之前提到的“空值缓存”和“主动删除”之间产生了冲突——流程是这样那条新闻下架时服务端主动删除了news:${id}的缓存但删除前刚好有一个请求发现缓存没有回源数据库也没查到因为已经下架了按防穿透策略往缓存里写了一个空值TTL 设了 3 天。于是后续所有请求都命中空值缓存新闻就“消失”了从用户侧看就是“下架了”从业务侧看是正常行为。但问题出在另一些请求用了旧版本客户端客户端本地磁盘缓存还存着这条新闻服务端缓存虽然下架了客户端却不会主动清就造成“下架三天还在显示”的假象。这个坑的教训有两条一是空值缓存 TTL 不能设太长我们后来统一改成 30 秒二是客户端本地磁盘缓存必须有版本控制服务端数据版本变化时要能强制客户端清理旧的本地缓存。6.5 优化上线后的日常三个每天要看一眼的监控项最后分享我现在每天都会看一眼的三个监控项建议做缓存优化的团队也把它们加到告警里缓存命中率服务端整体命中率低于 85% 时排查是否出现了缓存穿透或大量新 key 涌入Redis 内存增长曲线和 key 过期数量内存陡增可能是大对象写入key 瞬时集中过期可能是雪崩前兆回源数据库 QPS 曲线如果某个时间点回源 QPS 突然升高配合缓存命中率一起看基本能定位到是哪个 key 出了问题。我个人在实际操作中的体会是缓存机制从来不是一个“加一个 Redis 就万事大吉”的技术点它更像是一个贯穿客户端、服务端、数据库三端的系统工程。每一次 TTL 设置、每一个 key 的设计、每一层缓存的失效策略背后都是一次对业务场景的重新理解。上面这些方法和坑都是我们在真实流量下用线上故障换来的希望能帮你在做移动端首页优化时少走几段弯路。