
可能很多人看到“Redis缓存比数据库查询快”这种结论第一反应都是“哦教科书里都这么说”。但真正让我对这个差距有切身体会的是最近在苍穹外卖项目里做的一次压测同一个“根据分类查询菜品列表”的接口改造成Redis缓存链路之后平均响应时间从数据库查询的六十多毫秒降到了零点几毫秒实测倍率大约在116倍上下。这个数字不是靠理论推算出来的而是我在真实环境里用JMeter反复跑出来的。它背后涉及的不只是“内存比磁盘快”这一句话还包括了应用层序列化、网络传输、MySQL查询路径、Redis网络模型等一系列细节。这篇文章我会把这次实测的完整过程和代码落地方式拆开讲清楚——包括测试环境怎么搭、数据怎么解读、苍穹外卖里哪些接口适合加缓存、手动缓存和框架缓存分别怎么选、以及上线后最容易踩的缓存穿透、击穿、雪崩和一致性问题怎么处理。1. 排查起点一个餐饮项目C端接口为什么突然变慢1.1 从压测报告里看到的两个异常信号苍穹外卖这个项目相信做过Java全栈练习的都不陌生它分成用户端和商家端业务上对应的是真实的外卖场景用户浏览菜品、下单、支付商家管理菜品、套餐、订单。整体技术栈是SpringBoot MyBatis MySQL Redis典型的单体前后端分离架构。我这次遇到问题的接口在用户端“根据分类id查询菜品列表”逻辑上就是先从category表拿到当前分类再去dish表查出该分类下所有菜品最后拼装每个菜品的口味列表。代码逻辑本身不复杂单次查询的SQL也是走了索引的。但是在一次100并发、持续压测3分钟的测试里这个接口的平均响应时间从最初的单次几十毫秒飙升到了200毫秒以上数据库连接数从正常情况下的二三十个一路涨到接近连接池上限。再往下看监控数据库端的表现更直接QPS并不高但这个接口占了总查询量的三分之一以上而且代价不低——每次请求进来都要查至少三个表。更重要的是在一天里真正的高峰时段比如午间十一点到一点这个接口的调用频率会突然暴增数据库这时候就成了整个链路上最先扛不住的那一环。1.2 加索引和优化SQL之后为什么还是不理想接手排查的时候我一开始的惯性思路也是“优化SQL”先看了执行计划确认索引有没有生效然后分析是否有多余的关联查询尝试用JOIN合并再尝试减少DTO组装过程中反复查询菜品口味表的次数。这些优化做下来确实有效果接口响应时间从两百多毫秒降到了七八十毫秒。但从压测角度看这仍然不稳定——并发一高响应时间还是会上窜数据库的负载也没有本质上降下来。原因想清楚之后其实很简单MySQL查询的瓶颈不只是SQL本身还包括连接获取、SQL解析、执行计划生成、存储引擎的IO判断、结果集返回、ORM对象映射、DTO转换、JSON序列化这一整条链路。只要数据还在数据库里这些开销就不可能被“优化”掉。而菜品分类和菜品列表这种数据本身有非常明显的热点特征大量用户同时浏览同一批菜品数据本身却很少变化。这种场景天然就是为缓存准备的。1.3 决定做缓存直查对比前的预期管理我决定做一个最直接的对比测试相同的接口、相同的数据环境一路走原来的MySQL查询链路另一路先查Redis、没命中再回源数据库并回填缓存。我想用两组压力测试数据把“到底快多少”这个问题彻底量化。做之前我也给自己做了预期管理缓存带来的提速不会是一个固定倍数它取决于数据量、并发模型、网络延迟、序列化方式、Redis部署位置等多种因素。这个项目里的实测结果大约在116倍但这个数字的意义不是“Redis比MySQL快116倍”而是在当前条件下去掉数据库链路开销会让接口快一个数量级以上。这个结论对架构选型才有真正参考价值。2. 实测方案设计让116倍这个数字经得起推敲2.1 测试环境与关键参数为了让数据有说服力我先把测试环境固定下来避免“同一台机器上Redis和MySQL互相抢资源”导致结果失真。环境项配置说明应用服务SpringBoot 2.7JDK1.8单实例部署最大堆内存1GBMySQL5.7版本独立部署数据存储在同一块SSD上连接池默认配置Redis6.2版本单机模式关闭持久化AOF和RDB均关闭避免磁盘写入干扰内存查询压测工具JMeter 5.5线程数100Ramp-Up时间1秒持续3分钟数据规模category表50条dish表3000条dish_flavor表约9000条特别说明一下Redis关闭持久化只用于本次基准测试目的是把磁盘IO对结果的影响降到最低。在生产环境中必须根据业务允许丢失的数据量开启合适策略这个后面会聊到。2.2 测试方法预热、并发、请求数怎么定压测里有个很容易被忽略的点预热。JVM在刚启动时class还没有完全加载热点代码还没有被JIT编译直接压测得到的结果是不稳定的。我在正式测试前先跑了两分钟低并发请求让JVM把相关方法的字节码编译成本地机器码再开始收集数据。另一个容易出问题的地方是缓存命中率。如果直接用“冷缓存”测试Redis链路那每次请求都会穿透到MySQL测出来的是缓存完全失效时最坏情况的表现。所以我在正式压测前先调用了两次接口确保Redis里已经有数据再启动压测脚本让全部请求都走缓存命中的路径。每组压测的配置保持一致100并发Ramp-Up时间1秒持续3分钟两种链路交替各压三轮取三轮结果的平均值避免单轮数据抖动影响结论。2.3 实测数据全览与倍率计算先看数据库直查这组的核心结果指标第一轮第二轮第三轮平均值平均响应时间62.4ms64.1ms66.3ms64.3msTP95135.2ms142.7ms147.5ms141.8msTP99226.8ms231.4ms248.6ms235.6ms错误率0.06%0.11%0.07%0.08%再看Redis缓存链路这组指标第一轮第二轮第三轮平均值平均响应时间0.53ms0.56ms0.55ms0.55msTP951.1ms1.3ms1.2ms1.2msTP992.8ms3.5ms3.4ms3.2ms错误率0%0%0%0%两组平均响应时间一除64.3 / 0.55 约等于116.9也就是网上常说的“快116倍”。这个倍率之所以能拉这么大和测试环境关系很大——Redis部署在本机网络往返时间几乎可以忽略数据也全部在内存中。如果换成线上分布式环境Redis和应用不在同一台机器上网络RTT会有1ms左右的延迟倍率大概率会降到二三十倍这个后面我会展开说。3. Redis凭什么比MySQL快底层差距拆到底层3.1 内存和磁盘的物理访问差距Redis快的最根本原因是内存和磁盘之间数量级的物理性能差距。内存的随机访问延迟在纳秒级别大概几十到一百纳秒而普通SSD的随机读延迟在几十到一两百微秒如果是机械硬盘随机读延迟能到5-10毫秒。按照最常见的量级估算内存访问比SSD快约一到两个数量级比机械硬盘快三到四个数量级。MySQL这个例子更特殊一点虽然InnoDB有Buffer Pool理论上可以把热数据全部留在内存里但实际项目中数据库表多、数据量大Buffer Pool不可能无上限。尤其苍穹外卖这种带图片、带详情描述、带规格字段的表一行数据轻松超过几百字节同样的内存能装下的行数非常有限。一旦发生缓存未命中存储引擎就得去磁盘读页这一个动作的成本直接吃掉几十个内存访问的时间。3.2 MySQL的一次查询在路上做了什么要理解116倍是怎么来的不能只看存储介质。一次MySQL查询从进入到返回要经过完整链路应用从连接池获取一个数据库连接把SQL文本发送到MySQL服务器MySQL做词法解析、语法解析生成解析树查询优化器根据统计信息生成执行计划存储引擎根据执行计划去Buffer Pool中查找数据页如果Buffer Pool没有命中发生磁盘IO把数据页读入内存数据行返回给MySQL服务层再通过网络返回给应用应用侧MyBatis把这个结果集映射成Java对象再执行后续DTO组装比如循环查菜品口味列表最终将VO列表通过Spring MVC序列化成JSON返回给前端。这里面每一步都有开销尤其DTO组装时的“循环查表”在实际业务代码里很容易写成N1查询更是把数据库查询次数成倍放大。虽然我在这次压测前已经优化过这部分但MySQL链路的固定开销仍然非常可观。反观Redis链路请求的处理路径极短客户端命令通过网络进入RedisRedis的IO多路复用模型接收命令根据key找到对应的数据类型和数据结构在内存中直接读取数据并返回。没有SQL解析、没有执行计划、没有磁盘页判断、没有ORM映射整个过程干净利落。3.3 Redis的网络模型和数据结构红利很多人对Redis的误解是“它是单线程的所以并发能力不行”这是完全错误的。Redis的单线程模型恰恰是它性能稳定的关键之一避免了多线程上下文切换和锁竞争不需要像MySQL那样处理行锁、间隙锁、事务隔离的复杂问题所有命令在一个主线程内串行执行反而让每个请求的延迟变得非常可控。在IO处理上Redis用了多路复用模型配合epoll机制可以在单线程内同时监听成千上万个客户端连接。虽然Redis 6之后引入了多线程来处理网络IO但核心命令执行仍然是单线程所有操作保持原子性。这对缓存场景来说刚好合适——没有锁没有事务冲突读写速度就完全取决于内存操作本身。另外Redis的数据结构也很给力。像String类型的key查询底层就是一张哈希表平均O(1)复杂度列表、集合、有序集合也都有对应的跳表或链表结构。用在缓存场景里我们大部分时候只需要“按键取值”这种操作在Redis里属于开销最小的一类。4. 苍穹外卖场景下什么数据值得缓存先判断后动手4.1 适合缓存的数据特征与反例在写完缓存代码之前最重要的一步其实是判断哪些数据该缓存、哪些不该。我总结的判断标准就三条读多写少、实时性要求不高、数据量可控。反过来看苍穹外卖这个项目最典型的不适合缓存的数据就是订单。订单的写操作极度频繁状态从创建、支付、接单、配送、完成一路变化用户和商家都要即时看到状态任何缓存不一致都会造成实际业务错误。菜品库存、用户余额这类有强一致要求的字段同样不应该放缓存。适合加缓存的则是另一类ShopController的营业状态、CategoryController的分类列表、前端展示的菜品列表和套餐列表。这些数据有一个共同特点用户访问量大但更新频率非常低通常由商家在后台管理端修改修改完成后只需要及时清理一次缓存即可。4.2 用户端与商家端接口的缓存适配清单以苍穹外卖的功能模块为参照我把适合加上缓存的接口整理成了下面这张清单模块接口场景缓存Key示例过期时间说明用户端查看店铺营业状态shop:status30分钟状态极低频变化用户端查询菜品分类列表category:list60分钟分类变更极少用户端根据分类查询菜品列表dish:category:{id}60分钟菜品上下架有一定频率用户端根据分类查询套餐列表setmeal:category:{id}60分钟套餐变更频率低商家端查询菜品详情dish:detail:{id}30分钟详情被改动后需及时清理需要注意商家端的管理接口本身不建议直接做查询缓存因为管理端的写操作很多缓存一不小心就会读到脏数据。但我推荐的策略是把缓存逻辑集中在用户端的读接口上商家端在增删改完成之后主动删除对应key通过“更新数据库后删缓存”的办法来维持一致性。4.3 缓存key命名规范和过期时间策略缓存key命名看起来是个小事但实际排查问题的时候一个混乱的key体系会让人非常痛苦。我在这个项目里采用的规范是业务模块名:实体名:标识字段:值比如dish:category:1表示“dish业务下categoryId为1的数据”。这样在Redis客户端里可以清晰看到“哪些模块占了哪些内存”出问题时用KEYS dish:*就能定位关联的key集合。过期时间方面我习惯给不同数据设置不同的TTL而不是统一用一个值。菜品列表这种相对稳定的数据用60分钟店铺营业状态这种状态类数据用30分钟同时加上随机偏移量比如60分钟加减5分钟这个细节是为了防止后面要说的缓存雪崩问题。另外强调一个实测时的经验不管是哪种缓存方式都要处理“缓存穿透”场景下的DB空结果。比如用户查询一个不存在的分类id数据库查出来是空但如果不做处理下次同样的请求还会再去查库Redis永远替数据库挡不住无效流量。简单有效的办法是把空结果也缓存下来TTL设置短一些比如3-5分钟。5. 从手动缓存到框架缓存三种落地方式实测5.1 方式一RedisTemplate手动缓存控制力最强第一次改造我选择最朴素也最可控的方式在Service层直接用StringRedisTemplate以JSON字符串为缓存载体。以用户端“根据分类查询菜品列表”接口为例public ListDishVO listByCategoryId(Long categoryId) { String key dish:category: categoryId; // 1. 先从Redis读取 String cachedJson stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(cachedJson)) { return JSON.parseArray(cachedJson, DishVO.class); } // 2. 缓存未命中查询数据库 ListDishVO dishVOList dishMapper.selectByCategoryId(categoryId); // 3. 回填缓存设置过期时间 if (dishVOList null || dishVOList.isEmpty()) { // 空值也缓存防止缓存穿透 stringRedisTemplate.opsForValue().set(key, [], 5, TimeUnit.MINUTES); } else { stringRedisTemplate.opsForValue() .set(key, JSON.toJSONString(dishVOList), 60, TimeUnit.MINUTES); } return dishVOList; }这段代码的商业逻辑一目了然先查缓存命中就直接返回没命中查数据库把结果回填Redis下次再来就直接走缓存。手动方式最大的优点就是每一步都在你的掌控下想看什么、想统计什么都能自己加遇到特殊场景还可以在回填时做个性化处理。但缺点也很现实代码侵入性强每个需要缓存的接口都要重复写一套“判断缓存、回填缓存、设置过期时间”的模板代码。如果项目里有二三十个接口要加缓存维护量会变得很大。5.2 方式二Spring Cache注解缓存最省代码第二套方案是Spring Cache通过Cacheable、CachePut、CacheEvict这几个注解把缓存逻辑和业务逻辑解耦。改造后代码简洁非常多Cacheable(cacheNames dish, key #categoryId, unless #result.size() 0) GetMapping(/client/dish/list/{categoryId}) public ResultListDishVO list(PathVariable Long categoryId) { // 这里只写业务查询逻辑缓存由Spring Cache统一管理 ListDishVO dishVOList dishService.listByCategoryId(categoryId); return Result.success(dishVOList); }需要注意unless属性的作用当查询结果为空列表时不进行缓存这样做可以避免缓存大量无意义的空集合。如果想让空结果也缓存就得在Service层手动多做一步。在写操作上Spring Cache同样提供了非常方便的支持CacheEvict(cacheNames dish, key #categoryId) PostMapping(/admin/dish/save) public Result save(RequestBody DishDTO dishDTO) { // 新增菜品后直接删除该分类对应的菜品列表缓存 }Spring Cache的优点是声明式、侵入性小、可读性好业务代码里看不到任何Redis操作的痕迹团队协作时让对方理解“哪些接口有缓存”也变得非常容易。它的缺点是应对复杂缓存策略时不够灵活比如你想在回填时给数据做二次加工或者一个方法里同时操作多个缓存注解的表达能力就会显得捉襟见肘。5.3 方式三MyBatis二级缓存我为什么没用它网上还有一种做法是开启MyBatis的二级缓存让ORM层面自动缓存查询结果。我也在项目里试过但最终没有采用。原因有两个。第一MyBatis二级缓存默认是本地JVM缓存在单体部署时可用但在分布式多实例部署时每个服务实例的缓存是独立的一个实例改了数据另一个实例的缓存不会感知非常容易出脏读。第二配置不当非常容易引发序列化问题——二级缓存默认要求实体类可序列化如果实体里有复杂嵌套对象处理起来很繁琐。Redis作为集中式缓存天然规避了这两个问题所有实例共享同一个缓存层数据更新后只要删除对应的Redis key所有实例下一次请求就都会命中缓存并重新加载。所以最终我的结论很明确在SpringBoot项目里要么用RedisTemplate做手动缓存要么用Spring Cache注解MyBatis二级缓存更适合纯单机、无强一致的场景不适合作为主方案。5.4 三种方式下的响应时间实测对比我在同一环境里分别压测了手动RedisTemplate、Spring Cache注解、MyBatis二级缓存三套链路结果如下缓存方式平均响应时间TP99命中后的JSON解析开销RedisTemplate手动缓存0.55ms3.2ms每次请求都做JSON解析Spring Cache注解0.48ms2.9ms内部有序列化器略快于手动JSON解析MyBatis二级缓存0.40ms2.5ms无需额外反序列化直接返回对象MyBatis二级缓存本地内存读取确实比Redis慢不了多少但考虑分布式一致性和扩展性我最终在项目中全面采用了RedisTemplate手动缓存 局部Spring Cache注解的组合核心热点接口用手动方式精细控制普通读接口用注解快速落地。6. 缓存带来的隐藏雷区经典问题逐个拆解6.1 缓存穿透空值缓存与布隆过滤器的取舍加了缓存之后数据库的压力确实降下来了但缓存技术本身会给系统带来新的风险。第一个要处理的就是缓存穿透。缓存穿透的典型场景是用户拿一个不存在的id来查询比如分类id为99999Redis里完全没有这个key每次请求都会直接打到数据库数据库压力又回去了。更恶劣的是如果被人脚本循环轰炸数据库甚至会被拖垮。我在苍穹外卖里用了两层处理。第一层是缓存空值这个上面代码里已经写过查询结果为空时也设置一个短TTL的key第二层是缓存key的合法性检查在进入查询逻辑前先对参数做基础校验排除明显不该出现的id范围。对于更高强度的防护场景可以考虑在缓存前加一层布隆过滤器写操作时将分类id、菜品id等主键放入布隆过滤器查询时先判断id是否可能存在过滤器说“不存在”就直接返回空结果。这个方案对内存占用非常友好代价是会有极小的误判率实际项目中可以先从空值缓存开始等流量真正起来再上布隆过滤器。6.2 缓存击穿互斥锁的正确打开方式缓存击穿和穿透容易混淆。击穿指的是某个热点key在缓存过期的那一瞬间大量并发请求同时发现缓存未命中于是一起涌入数据库。举个典型场景一个菜品详情页在高峰期被每秒上千人访问缓存突然过期那1秒内的请求全部打到MySQL数据库连接数立刻打满。解决缓存击穿常见的做法有两种一种是互斥锁一种是逻辑过期。互斥锁的思路是“只让一个线程去重建缓存其他线程等待”我用Redis的SETNX实现public DishVO queryWithMutex(Long id) { String key dish:detail: id; String cachedJson stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(cachedJson)) { return JSON.parseObject(cachedJson, DishVO.class); } // 1. 尝试获取互斥锁 String lockKey lock:dish:detail: id; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); // 2. 获取锁成功查数据库并回填缓存 if (Boolean.TRUE.equals(locked)) { try { DishVO dishVO dishMapper.selectDetailById(id); stringRedisTemplate.opsForValue() .set(key, JSON.toJSONString(dishVO), 30, TimeUnit.MINUTES); return dishVO; } finally { stringRedisTemplate.delete(lockKey); } } // 3. 获取锁失败休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return queryWithMutex(id); }逻辑过期的方式则是缓存中不设置TTL而是把数据的过期时间写入缓存内容里例如存入{expire: 1700000000000, data: {...}}。读取时发现逻辑过期先返回旧数据同时开启一个线程去刷新缓存。这种方式响应更快但允许瞬间吐给用户旧数据适合对一致性要求不高的菜品展示场景。6.3 缓存雪崩过期时间抖动与高可用保障缓存雪崩是指大量key在同一时间集中过期或者Redis节点整体不可用导致所有请求同时打向数据库。和击穿不同击穿是一个key过期雪崩是一批key同时失效。处理雪崩我看到太多人只记住“设置随机过期时间”这一招但实际项目中这是远远不够的。我在这次实战里做了三件事一是在设置缓存过期时间时统一加上随机值。比如基础过期时间60分钟实际TTL设置为60分钟加一个0-5分钟的随机偏移这样即使同一批数据在相近时间写入过期时间也不会聚在一起。二是采用多级兜底在代码层面对查询异常做降级处理当Redis连接失败或超时不让请求直接打到数据库而是先返回一段短时间的本地缓存或空数据。这个降级策略在Redis故障时能有效保护MySQL不被瞬时流量冲垮。三是确保Redis本身的高可用。项目上线后不能只部署单机Redis至少要部署主从模式配合哨兵实现故障自动切换。如果流量规模更大可以进一步考虑Cluster集群模式让缓存容量和吞吐都能横向扩展。6.4 缓存与数据库的一致性更新数据库后怎么删缓存这是所有缓存场景里最容易被问崩的问题。更新菜品信息时是先更新数据库还是先删缓存删了缓存之后如果再次写入失败怎么办我在项目里采用的策略是**“先更新数据库再删除缓存”**也就是Cache Aside模式。为什么不是先删缓存再更新数据库因为在高并发下先删缓存会让一个本可以命中缓存的请求穿透到数据库而且两个并发写请求之间很容易造成旧数据被重新写回缓存。举个例子A请求先删缓存B请求后删缓存A事务还没提交时B已经把数据库更新完A提交后缓存被删看起来没问题。真正的风险在于A请求先删缓存紧接着C请求发现缓存为空从数据库读到了A事务提交前的旧数据写回缓存A再更新数据库这时缓存里就一直是旧数据。先更新数据库再删缓存可以有效规避这种场景因为更新数据库后缓存中的旧数据总会被下一次删缓存清掉。为了保证“数据库更新成功但缓存删除失败”时能自愈我额外引入了延迟重试删除缓存失败时将key推入延迟队列几秒后重试一次如果仍然失败就告警人工处理。对于变更无法立即感知的场景比如后台跨模块改了菜品数据但没走统一删除逻辑我会给缓存设置一个相对较短的TTL作为兜底让数据在最多一个过期周期内自然恢复。这套组合拳虽然不是最强一致性方案但在外卖项目这种业务场景下足够实用。7. 测试数据背后的复盘与上线顺序建议7.1 各组实测数据的最终汇总把前面几组数据汇总成一张总表方便整体对比来看链路平均响应时间TP99错误率适用场景MySQL直查原始200ms500ms0.3%不推荐在热点接口上用MySQL直查优化SQL后64.3ms235.6ms0.08%能用的底线但扛不住峰值RedisTemplate手动缓存0.55ms3.2ms0%热点业务、需要精细控制Spring Cache注解0.48ms2.9ms0%普通读多写少接口平均倍率约116倍约73倍明显更低—值得注意的是TP99的倍率约73倍低于平均响应时间的倍率原因是Redis链路的极值受GC和网络抖动影响相对更大而MySQL链路在压力增大时尖峰增长更夸张。但无论如何缓存方案在稳定性和错误率上的优势都是压倒性的。7.2 上线观察期我重点盯的三个监控指标缓存上线后不是万事大吉我给自己列了一个为期一周的观察清单围绕三个指标第一个是Redis命中率用INFO STATS里的keyspace_hits和keyspace_misses计算。命中率长期低于70%说明缓存key设计不合理或者过期时间设得太短白白浪费了Redis的内存。第二个是DB连接池活跃连接数。上线前压测时这一项经常打满缓存生效后应该稳定下降到一个明显更低的区间如果数据库连接数没有下降就要检查是不是有大量请求因为缓存逻辑问题穿透到了数据库。第三个是缓存Key的内存占用分布定期用redis-cli --bigkeys扫一遍大key和内存分布。菜品列表如果菜品数量特别多单个key的value可能达到几MB这种大key在高并发下会对Redis单线程模型产生阻塞需要及时拆分或改成Hash结构存储。7.3 一些实战里的经验心得整个改造过程走下来我个人有几点体会比较深。缓存不是越多越好先给最热的三五个接口加缓存跑出数据验证效果再逐步推广比一次性把所有读接口全部加上更稳妥。这个项目的经验是光是一个“根据分类查询菜品列表”接口就贡献了三分之一以上的数据库压力把它堵住之后整条链路的压力立刻降了一个量级。JSON序列化的选择会影响最终延迟。我对比过fastjson、Gson和Jackson在这个场景里fastjson在处理普通POJO反序列化时优势最明显但这只代表我们的数据模型下的结果不同项目的差异可以很大。建议用相同的方式先做个微型基准测试选最适合自己项目的序列化库。还有一点容易被忽略localcache永远比任何远程缓存都快。如果你连Redis的网络往返都觉得贵可以考虑在应用本地用Caffeine做一级缓存Redis做二级缓存。域名解析、热点数据、小型配置类数据都可以放到Caffeine里以微秒级延迟读取。但本地缓存也必须处理一致性它在分布式环境下比Redis更难保证多实例同步所以量力而行。最后测试结果受环境影响极大。我在本机压测时跑出过0.5ms的平均延迟但同样的代码部署到线上应用和Redis分属不同机器之后平均延迟变成1.5ms左右倍率降到三四十倍。116倍是“内存对磁盘、单机对单机、无网络瓶颈”这种理想条件下的结果它验证的是架构方向是否正确真正到了生产环境目标应该是把所有热点接口的TP99控制在50ms以内并保持数据库连接数稳定。指标到位缓存的价值就兑现了。