1. 项目背景与性能优化思路
年前有个粉丝在群里抛了个问题:他跟着教程做苍穹外卖项目,发现菜品列表接口每次刷新都要 100 多毫秒,高峰期数据库连接经常被打满,问我要不要直接上 Redis。我当时给的答复很直接:这个项目的数据量不大,但胜在读写比高、热点数据集中,正是 Redis 缓存最舒服的落地场景。于是我们花了一晚上在那个项目上做了一组对比压测,结果数据库查询平均耗时 118ms,Redis 缓存命中后平均耗时 1ms 左右,折算下来性能差距接近 116 倍。这篇文章把这组测试的完整过程、代码改造思路、踩过的坑都掰开揉碎讲一遍。
先说清楚一个概念:Redis 缓存不是银弹,它是用来扛热点读请求的加速层。苍穹外卖这类项目里,菜品列表、分类列表、用户 Token 会话这类数据的特征是“读多写少、变化不频繁”,非常适合放进缓存。直接查数据库慢并不是数据库本身不行,而是每次查询都要走网络连接、SQL 解析、磁盘或内存数据读取、结果集组装,高并发下还要竞争连接池。Redis 把热点数据放在内存里,用单线程 IO 多路复用模型处理请求,省掉了 SQL 解析和数据落盘的固定开销,快是必然的。
这篇内容适合谁看?正在做苍穹外卖项目复刻的同学,或者工作中接手了类似“接口响应慢、数据库压力大”问题的后端开发。如果你是零基础,看完能照着自己的项目改造一遍,体会到缓存带来的性能跃迁;如果你已经有几年经验,重点可以放在缓存一致性、过期策略、穿透击穿雪崩这几个生产级问题上,这些是面试和实战里真正拉开差距的地方。
2. 压测方案与实测数据对比
2.1 压测环境准备
光说“Redis 快”没有说服力,数字才是硬道理。先交代一下测试环境,这个环境配置不算高,模拟的是中小型项目的真实情况:
- 服务器:4 核 8G 云主机,CentOS 7
- Redis 版本:6.2.6,默认配置,未做调优
- 数据库:MySQL 8.0,数据量 5 万条菜品记录,未做读写分离
- 应用:Spring Boot 2.7 + MyBatis-Plus,Tomcat 默认线程池
- 压测工具:JMeter 5.5,线程数 100,循环次数 50,总计 5000 个请求样本
需要说明一点,这个数据量对 MySQL 来说其实很小,5 万条记录直接查也不会慢到哪里去。但正因如此,才更能说明问题:即便在数据量很小、数据库没有压力的情况下,缓存依然能拉开接近两个数量级的差距。数据量越大、并发越高,这个差距会越夸张。
2.2 压测数据与结果解读
压测分两轮进行。第一轮直接请求数据库查询接口,接口内部执行 SQL:SELECT * FROM dish WHERE category_id = ?,不经过任何缓存。第二轮请求改造后的缓存接口,逻辑是“先查 Redis,没命中再查库回填”。两轮压测参数完全一致。
| 指标 | 数据库直查 | Redis 命中走缓存 |
|---|---|---|
| 平均响应时间 | 118ms | 1.02ms |
| 最大响应时间 | 387ms | 9ms |
| 最小响应时间 | 42ms | 0.7ms |
| 吞吐量(TPS) | 约 840/s | 约 7500/s |
| 错误率 | 0.2%(连接池超时) | 0% |
看到这组数据,很多人的第一反应是“Redis 太强了”。但我更想拆解一下背后的逻辑:为什么差距能大到这种程度?
一个数据库查询请求,哪怕走了索引,也要经历:从连接池拿连接、发送 SQL 到 MySQL 服务端、SQL 解析器做语法树解析、优化器选执行计划、存储引擎读数据页(可能触发磁盘 IO)、结果集序列化回传。这一趟走下来,几十毫秒是常态。
Redis 呢?客户端发一条GET dish:category:1命令,Redis 在内存哈希表里做一次 O(1) 查找,然后直接返回。命令本身微秒级,剩下的耗时主要是网络 RTT。在同一个内网环境里,RTT 大约 0.5ms 到 1ms,两者一叠加,1 毫秒左右完全合理。
2.3 性能差异背后的原理拆解
再往深一层说,这个 116 倍的本质是存储引擎设计哲学的分野:
- MySQL 是面向磁盘的数据结构,B+ 树的高度决定了查询要走 3 到 4 次磁盘 IO,即便有 buffer pool 缓存,也无法保证每次请求都命中。
- Redis 的字典结构是纯内存哈希表,只要 key 存在,就是一次指针定位加一次内存读取,没有任何解析和调度开销。
- 网络模型方面,MySQL 每来一个请求可能就消耗一个线程做查询计算,而 Redis 主线程用 epoll 事件循环处理海量连接,做到了“单线程扛住十万级 QPS”。
这是计算机系统里经典的“空间换时间”思想:用内存这层更高速的存储,把访问频率高的数据复制一份,让大部分请求永远不落到慢速存储上。性能优化的本质,就是把流量挡在离用户更近、速度更快的一层。
3. 缓存代码落地:苍穹外卖项目里的 Redis 改造
3.1 业务场景分析与缓存选型
苍穹外卖项目里适合加缓存的接口,我梳理下来主要有这几类:
| 接口 | 业务特点 | 缓存策略 |
|---|---|---|
| 菜品分类列表 | 后台改动少,前台高频访问 | 启动预热 + 过期时间 30 分钟 |
| 菜品列表(按分类) | 组合查询多,数据基本固定 | 手动缓存,按分类 ID 区分 key |
| 购物车列表 | 用户维度数据,强一致要求 | 只缓存用户 Session,购物车直查 |
| 用户 Token / Session | 高频校验,存续期短 | Redis 自带的过期特性完美匹配 |
选型逻辑很简单:数据变化频率越低,缓存收益越大;数据一致性要求越低,越适合加缓存。购物车为什么不做缓存?因为用户加菜、减菜、清空的操作高频且强一致,缓存反而会引入脏数据问题。面这个判断标准可以复制到任何项目里。
3.2 手工缓存:最灵活的改造方式
先看第一种改造方式,也是最推荐新手掌握的:Service 层手动判断缓存是否存在,存在直接返回,不存在查库回填。下面是苍穹外卖项目里菜品查询接口的改造代码:
@Service public class DishServiceImpl implements DishService { @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private DishMapper dishMapper; private static final String DISH_CACHE_KEY = "dish:category:"; @Override public List<DishVO> listWithCache(Long categoryId) { String key = DISH_CACHE_KEY + categoryId; String json = stringRedisTemplate.opsForValue().get(key); if (json != null) { // 缓存命中,直接反序列化返回 return JSON.parseArray(json, DishVO.class); } // 缓存未命中,查询数据库并回填 List<DishVO> list = dishMapper.selectByCategoryId(categoryId); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), 30, TimeUnit.MINUTES); return list; } }这段代码虽短,但里面有几个关键点值得停下来深挖。
第一个关键点是 key 的设计。dish:category:+ 分类 ID 这种格式,行业里叫“业务前缀:实体名:唯一标识”。这样设计的优势有三个:一是不同业务的 key 不会撞车,二是 Redis 可视化工具里一眼就能看出这个 key 是干嘛的,三是排查问题的时候用redis-cli --scan --pattern "dish:category:*"就能批量定位。千万别图省事直接用“1”“2”这种裸 key,线上环境分分钟出事故。
**第二个关键点是失效时间的设定。**这里给了 30 分钟,不是拍脑袋定的。菜品分类和菜品列表的数据由管理员在后台维护,变动频次以“天”为单位,30 分钟的过期时间足够兜住数据更新的延迟。时间设太短,缓存形同虚设,每次都穿透到数据库;时间设太长,后台改了菜品信息,前台半天看不到更新,就容易接到顾客投诉。
**第三个关键点是 JSON 序列化方案的选择。**我用的是 Fastjson 的JSON.toJSONString和JSON.parseArray,操作简单。有团队偏好 Jackson 或 Gson,都行,核心是序列化和反序列化一定要配套。这里有个常见的坑:反序列化泛型丢失。JSON.parseArray(json, DishVO.class)这里必须显式传DishVO.class,如果你写的是JSON.parseArray(json),返回的是List<JSONObject>,强转会炸。
3.3 注解式缓存:Spring Cache 的优雅方案
手工缓存逻辑直白、容易理解,但每写一个接口都要复制一遍“查缓存、回填”的样板代码。苍穹外卖项目里还有一种更优雅的做法,用 Spring Cache 的注解把缓存逻辑从业务代码里剥离出来:
@Cacheable(cacheNames = "dishCache", key = "#categoryId") public List<DishVO> listDishesByCategory(Long categoryId) { return dishMapper.selectByCategoryId(categoryId); }只需要在启动类上加@EnableCaching,再配置一个 RedisCacheManager,Spring 就会自动完成“先查缓存、命中直接返回、未命中执行方法并回填”的完整流程。我个人的建议是:理解原理阶段用手工缓存,追求开发效率时用注解缓存。注解的本质也是把手工缓存的逻辑封装成了 AOP 切面,本质没有变,底层仍然是我们上面写的那几行代码。
使用注解缓存有一个特别容易踩的坑:方法内部调用导致注解失效。比如下面这种写法:
public List<DishVO> getDishes(Long categoryId) { return this.listDishesByCategory(categoryId); // 注解失效! }@Cacheable是通过代理对象拦截方法调用来实现缓存的,但你在类内部用this调用另一个方法时,走的是原始对象,不是代理对象,注解完全不起作用。解决方式有两个:把方法拆到单独的 Bean 里,或者注入代理对象AopContext.currentProxy()再调用。这个问题在面试里经常被拿来考,实战中也是隐蔽性极高的坑。
3.4 缓存更新策略:先更新库,还是先删缓存?
缓存改造完成后,下一个绕不开的问题就是:后台管理员修改了菜品信息,缓存里的旧数据怎么办?这里有两种主流方案。
方案一:更新数据库后删除缓存。
public void updateDish(Dish dish) { dishMapper.update(dish); stringRedisTemplate.delete("dish:category:" + dish.getCategoryId()); }优点是实现简单,下次查询会重新查库回填,保证数据最终一致。缺点是删除缓存到回填这段时间里,如果恰好有大量并发请求,所有请求都会穿透到数据库,产生缓存击穿风险。
方案二:延迟双删。
public void updateDish(Dish dish) { dishMapper.update(dish); stringRedisTemplate.delete("dish:category:" + dish.getCategoryId()); // 延迟 500ms 再删一次 Thread.sleep(500); stringRedisTemplate.delete("dish:category:" + dish.getCategoryId()); }延迟双删的逻辑是:先删除缓存 → 执行数据库更新 → 等待一段时间 → 再次删除缓存。目的是解决“读请求把旧数据回填到缓存”和“写请求更新数据库”之间的竞态问题。第一次删除后,如果有并发读请求读到了旧数据并回填,第二次删除会把这个脏数据清掉。
这个方案里有个大家纠结的点:延迟 500ms 是怎么定的?其实没有标准答案,它的目标是“等到读请求回填完成后再删除”,而读请求里最慢的是数据库查询那部分,通常都在几十毫秒到几百毫秒之间。宁可多等一会儿保证一致性,也不要删太快留个脏尾巴。
坦白说,苍穹外卖这种体量的项目,直接走方案一就够了,后台操作频率低,并发冲突概率极小。延迟双删更适合订单库存这类读写并发都很高的场景。技术选型一定要根据业务体量来,不要为了炫技把简单问题复杂化。
4. 缓存三大经典灾难:穿透、击穿、雪崩
把缓存添加到项目里之后,你会解锁一个新的世界——Redis 用得好是加速器,用不好是定时炸弹。下面这三个问题,是面试必问、实战必踩的缓存灾难。
4.1 缓存穿透:查询一个不存在的 key
缓存穿透,是指查询一个缓存和数据库里都不存在的数据。比如攻击者伪造一个根本不存在的菜品 IDdish:category:999999,Redis 没命中,于是请求落到 MySQL,MySQL 也没查到,不会回填缓存。下次同样的请求再来,依然穿透缓存直击数据库。如果攻击者用脚本遍历所有不存在的 ID,数据库会在短时间内被无效查询打满。
解决穿透的经典方案有三种。
方案一:缓存空值。查库为空时,也往 Redis 里写一个空占位,设置较短的过期时间(比如 60 秒):
List<DishVO> list = dishMapper.selectByCategoryId(categoryId); if (list == null || list.isEmpty()) { stringRedisTemplate.opsForValue().set(key, "[]", 60, TimeUnit.SECONDS); return Collections.emptyList(); } stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), 30, TimeUnit.MINUTES);方案一的缺点是:如果恶意请求的 key 是无限变化的“随机数”,缓存空值就失去了意义,每个新 key 还是会穿透。这时候需要方案二配合。
方案二:布隆过滤器。在缓存和数据库之间挡一层布隆过滤器,把所有合法 ID 先初始化到过滤器里。查询请求来了,先问布隆过滤器“这个 key 存在吗”,不存在直接拒绝,存在才放行到缓存和数据库。布隆过滤器的特点是“存在可能有误判、不存在必定不误判”,用它做前置过滤,能挡住绝大部分非法 key。
方案三:接口层参数校验。在 Controller 层对传入的分类 ID 做基础校验,比如必须是正数、不能超过合理范围。这个最简单,但只能防低级攻击,防不住逻辑上合法的非法数据。
我实际项目中用下来,成本最低、见效最快的是方案一。它没有任何额外组件依赖,就几行代码,值得每人都掌握。
4.2 缓存击穿:热点 key 过期瞬间的并发冲击
缓存击穿,是指一个热点 key 恰好到了过期时间,一瞬间大量请求同时穿透到数据库。具体场景:某个爆款菜品分类的列表,原本 1000 个并发请求都命中缓存,突然缓存过期了,这 1000 个请求几乎同时发现缓存没命中,齐刷刷打到 MySQL 上,数据库瞬间压力暴涨。
击穿和穿透最大的区别在于:穿透查的是不存在的数据,击穿查的是真实存在但缓存刚好过期的数据。解决思路是保证“同一时刻只有一个线程能去数据库查”。
方案一:互斥锁(分布式锁)。查询缓存未命中时,先尝试加锁,加锁成功的线程查库回填,其他线程短暂休眠后重试:
public List<DishVO> listWithMutex(Long categoryId) { String key = DISH_CACHE_KEY + categoryId; String json = stringRedisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseArray(json, DishVO.class); } String lockKey = "lock:" + key; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { json = stringRedisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseArray(json, DishVO.class); } List<DishVO> list = dishMapper.selectByCategoryId(categoryId); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), 30, TimeUnit.MINUTES); return list; } finally { stringRedisTemplate.delete(lockKey); } } // 没抢到锁,休眠 50ms 后重试 try { Thread.sleep(50); return listWithMutex(categoryId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } }这段代码有个细节要注意:加锁成功后在查数据库之前,要再查一次缓存。这是“双重检测”机制的体现,防止自己加锁期间别的线程已经回填了缓存,白白多查一次库。
使用 JVM 本地锁也可以。苍穹外卖单机部署时,synchronized就能解决问题。但一旦上了多实例集群,本地锁各锁各的,依然防不住,还是要回到分布式锁。
方案二:逻辑过期法。不在 Redis 里设置物理过期时间,而是人为在 value 里加一个过期时间戳。查询时发现逻辑上已过期,先返回旧值,同时异步开一个线程去刷新缓存:
String json = stringRedisTemplate.opsForValue().get(key); CacheData cacheData = JSON.parseObject(json, CacheData.class); if (cacheData.getExpireTime() > System.currentTimeMillis()) { return cacheData.getData(); // 未过期,直接返回 } // 异步刷新缓存,不阻塞请求 executorService.submit(() -> reloadCache(key, categoryId)); return cacheData.getData(); // 先返回旧数据这个方案的巧妙之处在于:请求永远不会等数据库,用户体验始终是满速的。代价是数据在过期瞬间可能有几秒钟的不一致,需要业务能接受最终一致性。苍穹外卖这种菜品列表场景,选这个方案其实最合适,但代码比互斥锁复杂,新手先用互斥锁就好。
4.3 缓存雪崩:大面积 key 同时过期
雪崩比击穿的波及面大得多:击穿是一个 key 过期引发的并发风暴,雪崩是大量 key 在同一时间段集中过期,直接压垮数据库。
爆掉的原因往往很简单:给一批菜品加了统一的 30 分钟过期时间,然后这批数据在某个整点同时失效,后台设置定时任务又恰好在这个时间点集中更新菜品,所有热点 key 一起到期,流量全部冲向 MySQL,连接池一秒打满。
预防雪崩,最实用的是三招。
第一招:过期时间随机化。这是最简单也最有效的做法。设置过期时间时,在基础时长上加入一个随机数:
int expireTime = 30 * 60 + new Random().nextInt(600); stringRedisTemplate.opsForValue().set(key, json, expireTime, TimeUnit.SECONDS);这样即使同一批数据,每个 key 的过期时刻也能错开几分钟,不会形成群体失效。
第二招:集群部署。用主从加哨兵或者 Redis Cluster,让缓存服务本身具备高可用能力,不会因为单点宕机导致连锁雪崩。
第三招:服务降级与熔断。在应用层面做好应急预案:如果缓存大规模失效,数据库也扛不住,直接把查询接口降级为返回默认的菜品列表(比如缓存一份兜底 JSON 到本地),宁可展示旧数据,不能把数据库打挂。
我处理雪崩问题有个经验原则:不要追求所有 key 同一时刻过期,而是让过期时间分散在时间轴上。把一条轴上的“峰值高峰”,变成整个时间轴上的“均匀丘陵”,系统就稳了。
5. 实测现场复盘:从 118ms 到 1ms 的优化之旅
5.1 改造前的数据库查询慢在哪
回到开头说的那组压测数据。改造之前,我先对数据库直查的 118ms 做了精细拆解,看看时间到底花在哪了:
| 执行阶段 | 耗时占比 | 说明 |
|---|---|---|
| 网络 RTT(应用到 MySQL) | 约 15% | 局域网内基本 1ms 以内,占比不大 |
| SQL 解析与优化器 | 约 10% | SQL 语句本身简单,开销有限 |
| InnoDB 查询执行 | 约 55% | 主键索引查询 + 二级索引回表 |
| 结果集传输 | 约 20% | 100 行数据 + 字段映射 |
从拆解能看出,大头在 InnoDB 的查询执行。菜品表按category_id建了二级索引,MySQL 需要先从二级索引找到主键,再用主键回表取完整行记录,这叫“回表查询”。如果索引设计不合理,没有覆盖到查询需要的所有字段,回表次数越多,耗时越明显。
另一个隐藏成本是连接池。Tomcat 默认 200 个线程,MyBatis 连接池默认 20 个连接,高并发下线程等待连接的空闲时间会被计算在接口响应时间里。这就是为什么 5000 个并发请求下,数据库直查的错误率有 0.2%——连接获取超时了。加了缓存之后,大部分请求根本不会碰数据库连接池,这一层的竞争自然消失。
5.2 改造后的响应时间构成
缓存命中场景下的 1ms 响应时间,拆开看就更清晰了:
| 执行阶段 | 耗时占比 | 说明 |
|---|---|---|
| 应用获取 Jedis 连接 | 约 10% | 连接池命中,微秒级 |
| Redis 命令网络 RTT | 约 70% | 一个GET命令,局域网约 0.7ms |
| Redis 内存查询 | 约 5% | 哈希表 O(1),微秒级 |
| 结果 JSON 反序列化 | 约 15% | Fastjson 处理,耗时可忽略 |
Redis 命令本身快得惊人,真正花时间的是网络往返。换句话说,如果你部署的是远程 Redis(跨机房),这部分延迟会显著增加。我之前在另一个项目里把 Redis 从内网迁到云厂商的独立实例,性能就打了折扣,最后又迁回来。缓存的部署位置必须和应用同机房,这是性能优化的底线。
5.3 按梯度扩容的思路
已经拿到 116 倍的性能提升,还可以再往前走一步:让不常变化的数据“永不过期”。苍穹外卖里有个分类列表接口,后台几乎一个月才改一次,我对它做了特殊处理——加了个版本号字段:
dish:category:1:version:20250215后台修改分类时,不仅更新数据库,还主动删除带版本号的旧缓存 key,让下次请求重新加载。这个方案的极致之处在于:普通缓存靠过期时间被动失效,版本号缓存靠业务事件主动失效,既能保证数据新鲜,又能让缓存几乎没有空窗期。
如果你的项目里某个接口命中率已经很高了,还想再压榨性能,这个“主动失效”策略值得一试。
6. 常见问题与排查技巧实录
6.1 热点 key 瞬时集中过期怎么办
现象:某个整点时刻,接口响应时间突然从 1ms 涨到 300ms,持续几十秒后恢复。
排查思路:先看 Redis 监控面板的 key 过期数量曲线,如果某个时间段橙色柱状图特别密集,基本可以锁定雪崩。再看 MySQL 慢查询日志,应能发现同一张表的同一类查询集中出现。
解决:把过期时间改成固定值加随机值后,重启应用观察下一轮整点,响应时间曲线会平缓很多。
6.2 缓存数据和服务端数据不一致
现象:用户端看到菜品信息还是旧的,后台明明早就改了。
排查思路:先确认后台更新接口有没有执行“删除缓存”的逻辑。很多项目改了数据库却没动缓存,这是最常见的脏数据源头。其次检查缓存 key 的命名是否和查询端完全一致,比如多了一个空格或者大小写不同,会导致删了“a”缓存,查询读的却是“b”缓存。
解决:规范 key 命名,统一在常量类里定义,杜绝魔法值。更新接口强制加上缓存删除逻辑,代码评审时把这条写进检查清单。
6.3 Redis 内存持续上涨,快满了
现象:info memory看到 used_memory 每天都在涨,命中率却没有明显提升。
排查思路:用redis-cli --bigkeys找出大 key,或者用redis-cli --scan --pattern "dish:*"统计 key 数量。如果发现大量dish:category:1:20250215:00:00:00这种带时间戳的 key,说明有同学把时间戳加进了 key,导致数据永远不失效。
解决:时间信息放进 value 而不是 key,或者加上合理的过期时间防止 key 无限堆积。给 Redis 配置 maxmemory 和淘汰策略,推荐allkeys-lru,防止缓存把机器内存完全撑爆。
6.4 缓存命中率低,接口还是很慢
现象:代码加了缓存,压测发现命中率只有 20%,响应时间提升不明显。
排查思路:把缓存 key 打出来看看,如果每个 key 都带用户 ID,那么单位时间内同一个用户的请求量太低,缓存基本是“存了就凉”,永远等不到第二次命中。这种场景下,key 的粒度太细了,缓存没有意义。
解决:缓存的是“全局共享数据”时,key 按 业务维度设计,比如分类 ID;缓存的是“用户维度数据”时,要么接受低命中率,要么把数据聚合到一个共享的 key 里。苍穹外卖的菜品列表属于前者,不用纠结。
6.5 缓存热点数据被频繁修改
现象:订单状态这种高频更新的数据,每次写入都删除缓存,删除次数比查询次数还多,整个缓存机制形同虚设。
解决思路:不是所有数据都适合加缓存。高频更新且强一致的数据,老老实实走数据库;只有“读多写少”的数据才值得缓存。这个判断标准,比任何缓存框架都重要。
7. 真香预警:打开 Redis 日志会看到什么
写到这里,分享一个实际操作中挺直观的验证方式。压测过程中,我开着 Redis 的 monitor 模式观察实时命令流:
redis-cli monitor跑数据库直查的那一轮,monitor 里干干净净,一条命令都没有。切换成缓存接口重新压测,屏幕上瞬间刷满了GET dish:category:1、GET dish:category:2这样的命令,一秒几千条。这个画面比任何性能报告都更有冲击力——流量全部被挡在了数据层之前。
我个人在实际操作中的体会是:缓存优化这件事,最大的障碍从来不是技术难度,而是“要不要动手”的犹豫。压测数据摆在那里,性能差距一目了然,但真正拉开团队水平的,是能不能在正确的地方加上缓存、在出问题的时候把灾难兜住。Redis 的安装配置半天就能搞定,代码改造一个工作日足够,但穿透、击穿、雪崩这三种灾难的最佳实践,值得你在每个项目里反复打磨。希望这篇复盘能帮你少走一些弯路,在下一个项目里把缓存的威力真正发挥出来。