ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

缓存机制实战:移动端首页加载与数据库压力优化全解析

缓存机制实战:移动端首页加载与数据库压力优化全解析 1. 先说个真实的首页卡顿现场首页加载慢、数据库压力大是移动端App两个最叫人头疼的老问题而且它们经常是一起出现的。用户点开App首屏转圈3秒以上服务端数据库连接数被打满后面所有请求都在排队。我去年接手的一个电商类App就是这个状态首页要拉十几个接口每个接口都直查数据库高峰期CPU和连接池全在告警。那段时间的优化重点其实没有加任何新功能就是把这套首页的加载链路用缓存机制重新梳理了一遍。缓存不是高深概念但真正落地时会发现内存、磁盘、数据库、接口、图片全部掺在一起任何一个环节没考虑到效果都会大打折扣。这篇文章就以我的实际项目为例把缓存机制的实战细节从头到尾讲一遍。内容围绕两个目标第一是提升移动端首页加载效率让用户进App就能看到内容第二是降低数据库的压力让后端服务在高并发下不被打死。适合正在做App性能优化、或者总被“首页慢”和“数据库告警”折磨的开发同学参考。我会把设计思路、落地代码、参数选择、以及线上踩坑都写出来尽量说人话能直接抄作业的那种。2. 动手设计缓存前先把这四个问题想清楚很多人做缓存一上来就写个Map或者Redis结果上线没几天就出乱子。不是缓存没生效而是因为没想清楚数据到底该怎么存、存多久、什么时候失效。缓存机制本身不难难的是在具体业务约束下选对方案。我在动手前一般强迫自己回答四个问题回答完了代码基本就有谱了。2.1 这份数据多久变一次能接受多旧做缓存第一件事不是选技术而是搞清楚业务对数据新鲜度的容忍度。同样是首页商品推荐位的数据可以接受10分钟内是旧的但用户购物车数量、订单状态这种数据最好每次进页面都是新的。还有库存、价格这种敏感字段旧数据会直接引发投诉。我的习惯是给每类数据标注一个“新鲜度等级”强一致数据比如订单状态、支付结果不做缓存或者只做极短时间缓存弱一致数据比如首页推荐、榜单、运营配置可以接受几分钟甚至更长时间的延迟。这个判断直接决定了后面的TTL策略和缓存层级。另外还要想清楚一个容易被忽略的问题数据变化是谁发起的。如果是运营后台改配置那可以通过推送或者接口触发主动失效如果是用户自己产生的数据比如收藏、浏览记录那就需要在本地写入成功后同步更新缓存而不是等TTL自然过期。2.2 缓存放在哪一层内存、磁盘还是数据库移动端App的缓存可以放在三个地方内存缓存、磁盘缓存、本地数据库缓存。这一块很多初学者容易搞混总以为用一个LruCache就完事了实际远远不够。内存缓存的优势是最快读取是纳秒级但它有一个致命问题进程被杀就没了而且内存空间有限App长时间运行还面临系统回收风险。磁盘缓存用文件存数据优点是持久化App冷启动后还能用缺点是读取速度比内存慢好几个数量级但远比网络请求快。数据库缓存则是用SQLite这类本地数据库做结构化存储适合数据量大、需要查询过滤的场景比如历史列表、消息记录。我实际使用的组合是三层配合内存缓存负责“热数据”让用户在当前会话内滑动不卡顿磁盘文件缓存负责“启动后首屏”让冷启动也有数据可用数据库则承担“结构化历史数据”的存储比如首页列表分页缓存、搜索历史这种。三层之间不是替代关系而是各管一段。2.3 失效策略怎么选TTL、LRU还是主动失效缓存失效策略是最容易翻车的地方。常见的做法有TTL过期时间、LRU最近最少使用淘汰、主动失效以及它们之间的组合。TTL好理解给每个缓存设置过期时间时间到就删除或者重查。但TTL设置太短缓存效果差设置太长用户看到过期数据。我一般不给整个App统一TTL而是按接口维度配置。比如首页推荐接口TTL给5分钟用户信息接口给30秒运营公告这种给1小时每个接口一个配置项方便线上动态调。LRU解决的是空间问题。内存缓存不能无限增长超出上限后要用LRU策略把最久没用的数据淘汰掉。Android上的LruCache就是这个思路关键参数是maxSize需要根据设备内存和页面数据量测试决定。我踩过的一个坑是maxSize设置太大导致内存缓存占用过多系统内存紧张时反而引发更多GC页面滑动更卡。主动失效是解决一致性问题的手段。当数据被修改时比如用户改了个人的头像本地缓存和磁盘缓存要立刻删除保证下次读取是新的。主动失效的优先级要高于TTL因为TTL只能保证“最终一致”不能保证“立即一致”。2.4 数据一致性先更新数据库还是先写缓存这个问题的本质是缓存和数据库的数据谁为准。移动端本地开发我遇到的场景基本都是“本地数据库是唯一数据源缓存只是加速层”。我的处理顺序是先写本地数据库数据库写入成功后再更新内存缓存和磁盘缓存同时更新缓存中的版本号。这样做的原因很简单如果先写了缓存但数据库失败那缓存数据和数据库就对不上了下次任何一次缓存淘汰数据就直接丢没了。反过来先写数据库即使缓存更新失败了最差的情况是用户读到一次旧数据但数据库是对的影响范围可控。有一个可复用的小技巧给每份缓存数据附带一个version字段。每次读取缓存时都带上这个version在网络请求或者后台刷新返回后对比新数据的version和缓存里的version。如果新版本号更大就覆盖缓存如果小于等于当前版本说明后台数据没有变化可以直接丢弃。这个方案能省掉不少无意义的缓存写入操作。3. 首页加载效率的缓存实战从冷启动到首屏渲染首页加载之所以慢核心原因是“冷启动后一切都要重新来”。网络请求要时间DNS解析要时间图片下载要时间数据库查询要时间把这些时间串起来3秒就没了。缓存优化的目标就是把这条串行的链路变成并行而且大部分数据变成“先读本地再后台更新”。3.1 三级缓存架构内存、磁盘、数据库怎么配合我在首页落地时把缓存链路设计成了一个读数据的顺序先去内存缓存找没找到就去磁盘文件缓存找再没找到才发起网络请求。拿到网络数据后先落数据库或磁盘文件再写内存缓存保证下一次读取能命中。这里面的关键点有两个。第一读取顺序必须是内存 → 磁盘 → 网络不能跳过磁盘直接在内存没命中后就发请求否则冷启动场景没有任何优化效果。第二每一级缓存的“数据格式”要提前定义清楚。内存缓存放的是已经解析好的对象方便直接绑定UI磁盘和数据库缓存放的是原始JSON或者可序列化的结构便于恢复。如果把对象直接存磁盘一旦代码里数据模型改了字段反序列化就会出问题最好在磁盘层存协议原始数据再在读取时暴力解析。很多App首页还会做“预加载”在用户到达首页前比如在闪屏页阶段就开始拉取首页接口等用户真正进入首页时数据已经在内存缓存里了。这个方案能极大缩短首屏等待时间但要注意并发控制避免预加载的请求和页面自己发起的请求打架我用的办法是给预加载请求打tag页面加载时先检查这个tag对应的数据是否存在存在就直接用否则再发新请求。3.2 冷启动首屏优化先用缓存撑一撑冷启动是首页性能最差的场景。App进程刚起来内存是空的所有缓存都要重新加载。这里我的做法是“缓存先行网络补充”用户点进首页先把磁盘缓存里的上一版首页数据加载出来立刻渲染页面骨架同时后台发起接口请求等新数据到了再更新界面。这个方案在弱网环境下特别重要。有一次我们把App放在地铁里测试地下站网络信号很差接口请求超时但没有做磁盘缓存的那一版页面直接白屏做了缓存先行的版本用户看到的是上一次打开App时的首页内容虽然数据是旧的但用户能正常浏览商品体验比白屏好一百倍。实现上有一个环节容易被忽略磁盘缓存的读取是IO操作不能在主线程执行。我第一次实现时直接在Activity的onCreate里读文件结果页面启动反而更卡后来改成了异步读取先渲染骨架屏等磁盘缓存读完了再填充数据。这里要注意页面的生命周期异步读取返回时Activity可能已经销毁需要做是否有View绑定的判断。3.3 首页接口延迟加载与数据合并接口缓存还有一种更细的做法把一个大接口拆成多个子接口每个子接口单独缓存然后在前端合并展示。比如首页的运营位、商品流、公告栏变化的频率完全不一样。运营位可能一周才改一次商品流每分钟都可能有新数据公告栏几个小时一变。如果这些内容放在同一个接口里任何一个业务改动都会导致整个缓存失效拆开了每个模块按自己的节奏更新命中率能高不少。我拆过的一个例子是首页原来一个接口返回60KB数据里面包括配置信息、商品列表、用户状态。用户状态显然不适合长缓存而配置信息几乎不变。拆分后用户状态走独立接口并且不做缓存配置信息和商品列表各自缓存首页首屏真正依赖的接口响应从60KB降到了20KB加载速度肉眼可见提升。这里需要提醒一点接口拆分增加了后端请求次数需要统计一下总体流量和耗时不是所有场景都适合拆。如果拆完导致用户每次开首页要发6个请求比原来一个请求更慢那就得不偿失。我的判断标准是拆分后的总网络耗时不应该超过原来单个大接口的耗时否则就保留原来的聚合接口。3.4 图片缓存首屏最大的隐形杀手首页加载效率里图片往往是权重最大的问题。文字数据哪怕全部从网络实时拉几十KB也就回来了图片不一样一张高清图动辄几百KB甚至几MB如果没有图片缓存首屏会卡得非常严重。图片缓存的核心是三级内存中的ImageCache、磁盘文件中的图片缓存、以及服务端CDN。客户端拿到图片URL后先去内存里找找到直接用找不到就去磁盘找找到后解码成Bitmap放入内存还没有就交给图片加载库比如Glide、Coil去网络请求请求完成后写入磁盘和内存。这里最容易被忽略的是图片解码对CPU的消耗。同一张图如果原始分辨率是4000x3000而显示区域只有400x300不解码直接加载内存占用会非常夸张首屏会出现明显掉帧。正确的做法是加载时指定目标尺寸让图片库做采样压缩。Glide里用override(width, height)指定尺寸Coil里用size(Size)这个操作通常能降低80%以上的图片内存占用。另外一个关于图片缓存的小技巧图片的缓存key不要直接用URL而是用“URL 图片宽度 图片高度 处理方式”拼接。因为同一张图在不同位置可能请求不同的尺寸如果只用URL做key会互相覆盖导致图片反复重新加载。加了尺寸之后只要尺寸不变就能稳定命中缓存。4. 数据库性能优化把压力留在缓存层首页慢的另一半原因在数据库。客户端本地数据库SQLite虽然比服务端数据库轻量但高频读写、复杂查询、无索引全表扫描一样会卡。这一块的优化思路核心就是“减少无用查询”和“让查询走索引”。4.1 数据库慢查询的源头在哪里先说说本地数据库什么时候会慢。第一是全表扫描数据量大了以后没有索引的查询会一条一条扫过去速度随数据量线性下降。第二是频繁的写入和删除导致页分裂、文件碎片化SQLite的B树不断调整IO放大明显。第三是事务太长一个事务里做几十条insert中间还查询锁竞争严重。第四是主线程访问数据库任何一条稍慢的查询都会导致页面卡顿。我之前遇到过一个真实案例首页有个推荐列表每次下拉刷新都把本地之前存的20条推荐记录删掉再插入新的20条。代码逻辑没问题但线上用户反馈首页经常卡住。后来把数据库日志打开一看发现每次刷新都要执行“delete 20次insert”总共30秒才能完成所有写入主线程直接卡死。后面改成事务批量操作一次事务里完成删除和插入耗时从30秒降到了1秒以内。4.2 索引不是越多越好按查询频率来索引是数据库性能优化最直接的手段。首页列表经常按时间倒序查就给timestamp字段加索引经常按分类ID过滤就给categoryId加索引。但索引多了也有问题写入时需要同步更新索引插入速度会变慢占用空间也会增加。我在移动端数据库上做索引的经验是先看慢日志里哪类查询最多再针对高频查询建索引。不要提前把所有字段都建上索引也不要为了“未来可能用到”而建索引。一个实际例子是我们的消息列表按时间分页查询给时间字段建索引后分页查询速度从几百毫秒降到了几十毫秒效果立竿见影。还有一个很多人忽略的点复合索引的字段顺序很重要。如果查询条件是“WHERE type ? ORDER BY time DESC”那么建(type, time)复合索引比分别建两个单字段索引效果好得多。SQLite可以按最左前缀原则匹配字段顺序放对了一条索引能同时加速过滤和排序。4.3 批量写入与事务合并数据库写入是移动端性能的一大坑。很多人写代码习惯一次插入一条数据循环几十次每次插入都自动开一个事务提交一次磁盘IO被反复刷速度极慢。优化的做法是手动开启事务把所有写入操作放进同一个事务里统一提交。以SQLite为例执行beginTransaction循环insert最后setTransactionSuccessful和endTransaction。实测下来同样是插入500条记录逐条插入耗时在8秒左右合并成一个事务后耗时降到200毫秒以内差距是几十倍。这里的原理其实不复杂事务的提交涉及磁盘同步、日志写入等开销如果每条操作都提交一次这些固定开销被重复了很多遍。合并成一个事务后固定开销只付一次剩下的就是真正的数据写入。代价是事务期间数据对外不可见但对于批量导入、后台刷新场景来说完全没问题。另外批量写入时可以用“预编译插入语句”来加速。SQLite的INSERT语句每次都要解析SQL如果先compileStatement编译一次再反复绑定参数执行省掉了SQL解析的时间。我自己的测试数据是500条记录的插入预编译比直接执行SQL再快50%左右。4.4 ORM框架与查询结果缓存现在很多App使用Room、GreenDAO这样的ORM框架操作数据库。ORM的好处是省心但性能上容易埋雷。最典型的问题是N1查询一个列表数据模型有关联比如文章列表查询了文章然后循环里再根据每个文章的authorId去查作者表结果几百条数据就变成了几百次查询。ORM框架一般都提供关系映射和查询注解如果发现循环查询第一反应应该是用join或者自己拼接数据而不是放任ORM做隐式查询。另一个问题是ORM的映射过程本身有开销查询返回100条记录ORM要创建100个对象反射赋值这个时间累积起来也不小。对于大数据量列表页我有时候会直接使用原生SQL查询加上手动映射稳定性更好。查询结果缓存这块我的做法是对“稳定数据”加一层内存缓存。比如用户配置表、城市列表这种基本不变的数据第一次从数据库读出来后放入一个ConcurrentHashMap后续读取直接走内存DB查询次数几乎降为0。注意这个缓存要设置更新入口在数据被修改后清掉避免脏数据。4.5 数据同步用版本号代替全量查询数据库性能优化到了最后还要考虑一个更宏观的问题客户端本地数据库和服务端的数据怎么同步。很多App的做法是每次进入页面就全量拉取一遍然后整个表清空重插。这种方式逻辑简单但数据库压力最大流量也浪费。我目前用的方案是“增量同步 版本号”。服务端每次更新数据时携带一个全局版本号或者更新时间戳客户端本地也记录一个版本号。每次同步请求只带上本地的版本号服务端返回比这个版本号新的数据客户端只更新这部分增量数据再更新本地版本号。这个方案落地后首页列表的数据库写操作从原来的每次全量几百条变成了偶尔几条。数据库压力大减主线程卡顿也基本消失了。需要注意版本号机制的正确性版本号要全局递增不能因为某个数据表更新失败导致版本号回退。我一般是在一个独立的meta表里存版本号更新成功后再更新meta保证数据一致。5. 缓存与数据库优化的踩坑实录缓存和数据库的优化最难的不是实现而是上线之后出现的各种“反直觉”问题。这些问题很隐蔽不压测根本发现不了。我在这里整理几个真实的坑和排查思路大家可以对照自己的项目看看。5.1 缓存命中率低、命中率虚高分别怎么查缓存做完了心里得有本账。我线上统计两个指标一个是缓存命中率就是读缓存成功的次数占总读取次数的比例另一个是缓存更新次数看缓存是否被频繁重复写入。命中率低的常见原因第一是缓存key设计不统一。同一个数据在页面A用“home_list_1”做key页面B用“home_list_v2_1”做key两边各写各的看起来都在缓存实际没有任何复用。排查思路是统一缓存key的生成规则必须包含业务模块、数据类型、数据ID、版本号几个维度。第二是TTL设置太短缓存刚写入就过期下一次请求又穿透到数据库。这种情况需要把TTL拉长或者改成“过期后先返回旧数据再后台刷新”的策略。命中率虚高反而是另一个坑。如果缓存key中包含时间戳、随机数这种每次都变的参数那每一次读取都不可能“命中”同一个缓存但统计时因为读操作返回了非空数据就误以为是命中。这是典型的key设计错误必须把动态参数从key中去掉或者只在value层面变化。5.2 缓存不一致和数据错乱问题印象最深的一次线上事故用户修改了个人昵称数据库里已经更新成功但首页和我的页面显示的还是旧昵称。排查发现昵称同时被两个模块缓存了一个模块用的key是“user_profile_1001”另一个模块用的key是“mine_user_1001”修改操作只清掉了“mine_user_1001”首页那个没清导致部分场景读取旧数据。这其实是缓存一致性问题最常见的原因缓存入口不统一。后来我把所有和用户信息相关的缓存key收拢到一个CacheManager里所有模块都通过它读写用户信息清缓存的时候也能统一清理。类似的问题也出现在商品信息上详情页改了价格列表页的缓存没更新用户点进详情看到的和列表上不一致投诉马上就来了。解决一致性问题的通用手段有三个第一更新数据库后主动删除相关缓存而不是等待过期第二设置一个兜底TTL即使主动删除失效缓存最终也会过期不会永远错下去第三数据写入时带上版本号每次读取时校验版本发现后台版本更新就强制重拉。5.3 弱网环境与缓存异常弱网是缓存问题的高发期。网络不稳定时接口请求超时App如果只依赖网络数据页面就废了如果依赖缓存又容易读到过期数据。我之前遇到的情况是用户在电梯里网络很差首页读取了磁盘缓存里的旧数据页面能展示但下拉刷新时请求一直失败刷新动画也一直转圈用户以为App卡死了。后来优化了刷新逻辑刷新失败时如果本地有缓存就直接提示“当前网络不畅已显示上次缓存内容”而不是一直转圈等待。这种用户体验比干等好得多。还有一个细节弱网环境下网络请求的timeout时间要设置得短一点比如10秒不要用默认的30秒否则用户等待时间过长更容易判断App已死。弱网下还要注意缓存数据的完整性。磁盘缓存写入时如果出现异常比如空间不足、中断写入可能留下一份半截的数据下次读取时反序列化就会崩溃。我的解决办法是写入时先写临时文件写完再rename成正式文件读取时如果解析失败直接删除缓存文件并按无缓存处理避免重复崩溃。5.4 内存缓存抖动与GC卡顿内存缓存虽然快但用不好会带来GC问题。有个阶段我们的首页每次刷新都创建大量的新对象同时回收旧的Bitmap结果GC频繁触发列表滑动明显掉帧。排查后发现问题出在两个方面一是图片缓存设置了过大的maxSize内存中同时存在太多大图二是列表数据每次刷新都是“用过即弃”没有复用对象。内存缓存设计的要点是“宁可少不要多”。给LruCache设置maxSize时我一般按Bitmap总数的估算来算比如每张图平均占用1MB页面最多展示10张那么图片缓存maxSize设15MB左右比较合理再大就是浪费。滑动列表时还要利用RecyclerView的ViewHolder复用机制避免每次滑动都创建新的View和解析新的图片对象。这里有一个实测效果很好的做法列表滑动过程中暂停图片加载完全停止滑动后再加载当前可见item的图片。这样能显著减少滑动期间的图片解码次数卡顿明显下降。实现上可以通过RecyclerView.OnScrollListener判断状态滑动时暂停Glide请求停止时恢复。6. 一些踩过之后觉得值得记住的经验写到这里缓存机制的实战链路基本完整了。回头看最值得记下来的不是某个具体技术而是几个原则性的东西。第一个原则缓存永远要有兜底。无论TTL配多久总有可能出现极端情况比如服务端接口宕机、本地缓存文件损坏、版本升级后数据结构不兼容。我的处理方式是所有缓存读取逻辑都包一层try-catch读取失败就按无缓存处理同时把异常上报到监控平台。缓存是优化手段不是正确性依赖不能让缓存组件崩溃拖垮整个页面。第二个原则监控比实现重要。如果不统计命中率、不观察TTL过期次数、不追踪数据库慢查询那缓存优化就是盲人摸象。我上线缓存后会重点盯三个指标首页从点击到首帧渲染的时间、数据库单次查询平均耗时、缓存命中率趋势图。这三个指标一起看才能判断缓存到底有没有生效。第三个原则不要过度设计。首页加载优化不是把所有数据都塞进缓存就完了。有些接口响应数据量极小直接查询数据库也就几十毫秒给它加一层内存缓存反而增加了复杂度还要处理一致性问题。我的判断标准是如果一个接口平均耗时已经低于100毫秒且调用频率不高那就不值得加缓存只有那些“调用频繁”或者“耗时高”的数据才值得认真设计缓存层级。第四个原则多测试几种网络环境。我在优化完后习惯用Charles或者抓包工具模拟弱网、断网、高延迟环境分别验证页面表现。很多缓存问题只在弱网下才会暴露等用户来投诉就晚了。最后分享一个小经验缓存相关的代码一定要集中管理不要散落在各个页面。我最终把所有缓存读写、key定义、TTL配置都收拢到了一个CacheManager模块对外只暴露get和put两个方法。这么做的直接好处是线上如果发现缓存key出了问题只需要改一个文件就能全部修正如果散落在几十个文件里排查成本会高到让人崩溃。缓存机制这件事技术本身不难难的是把它当作一个系统工程来做想清楚每一份数据该存在哪、该活多久、该怎么失效然后稳扎稳打地落地。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进