从数据库直连到缓存预热:城市列表查询的性能优化全流程
目录
1.前言
插播一条消息~
2.正文
2.1直接从数据库查询
2.2先从缓存查询
2.3二级缓存方案
2.4缓存预热
3.小结
1.前言
大家好!不知道大家有没有注意到,当我们打开电商APP首页准备下单时,第一步往往是选择所在城市——这个看似简单的操作背后,其实隐藏着一个高频访问的核心接口。城市列表查询接口作为用户触达服务的“第一扇门”,其性能直接影响用户体验:如果接口响应慢1秒,可能就会让用户失去耐心;如果并发量突增导致接口不可用,甚至会直接阻断交易流程。
然而在未优化的系统中,这个接口往往面临着“成长的烦恼”。想象一下,当百万级用户同时打开APP,每次城市选择都直接查询数据库,就像超市只有一个收银台却迎来购物高峰——数据库连接池被占满、查询排队、响应延迟,甚至出现“结账通道瘫痪”的窘境。
优化就像超市的收银系统升级:从最初的单收银台(纯数据库查询),到增加自助结账机(本地缓存),再到设置快速通道和会员专属通道(二级缓存架构),每一步演进都是为了在“用户体验”和“系统成本”之间找到最佳平衡点。接下来,我们就将详细拆解这个高频接口从“数据库直连”到“多级缓存架构”的完整优化历程,看看如何通过技术手段让这个“城市选择”的小动作,既轻快又可靠。
插播一条消息~
🔍十年经验淬炼 · 系统化AI学习平台推荐
系统化学习AI平台https://www.captainbed.cn/scy/
- 📚 完整知识体系:从数学基础 → 工业级项目(人脸识别/自动驾驶/GANs),内容由浅入深
- 💻 实战为王:每小节配套可运行代码案例(提供完整源码)
- 🎯 零基础友好:用生活案例讲解算法,无需担心数学/编程基础
🚀 特别适合
- 想系统补强AI知识的开发者
- 转型人工智能领域的从业者
- 需要项目经验的学生
2.正文
2.1直接从数据库查询
V1 版本实现代码
/** * 城市列表查询 V1 * @return 城市列表信息 */
public List<SysRegionDTO> getCityListV1() { // 声明一个空列表用于存储结果List<SysRegionDTO> result = new ArrayList<>(); // 查询数据库中的全量区域数据List<SysRegion> list = regionMapper.selectAllRegion(); // 循环遍历全量数据,筛选城市级别记录并转换对象for (SysRegion sysRegion : list) { if (sysRegion.getLevel().equals(MapConstants.CITY_LEVEL)){ SysRegionDTO sysRegionDTO = new SysRegionDTO(); BeanUtils.copyProperties(sysRegion, sysRegionDTO); result.add(sysRegionDTO); } } return result;
}
核心逻辑解析
该版本通过三个关键步骤实现城市列表查询:首先初始化结果列表,然后调用 regionMapper.selectAllRegion()
从数据库查询全量区域数据,最后通过循环遍历全量数据,筛选出级别为 MapConstants.CITY_LEVEL
的记录,并通过 BeanUtils.copyProperties
完成 SysRegion
到 SysRegionDTO
的对象转换。这种实现将数据筛选逻辑完全放在应用层处理,数据库仅负责返回原始数据。
核心问题类比:该方案类似于"每次需要少量商品时,都要从仓库将所有货物全部搬出来逐一挑选"——即使只需要城市级别数据,也必须先加载全量区域信息,如同将整个仓库的货物搬到现场后再筛选目标物品,导致大量无效资源消耗。
性能瓶颈分析
- 数据库连接资源占用:每次请求均需建立数据库连接并执行查询,连接资源在高并发场景下会迅速耗尽,引发连接超时或排队等待。
- 全表扫描耗时:
selectAllRegion()
操作执行全表扫描,当区域数据量达到万级以上时,单次查询耗时显著增加,且随数据量增长呈线性上升趋势。 - 应用层冗余计算:全量数据加载后,应用层需遍历所有记录进行级别筛选和对象转换,进一步占用 CPU 资源并延长响应时间。
实际性能测试显示,在 10 万级区域数据量下,该接口平均响应时间达 500 ms,且在并发量超过 50 QPS 时出现明显的响应延迟和数据库连接池耗尽现象,无法满足生产环境的性能要求。
2.2先从缓存查询
V2 版本针对 V1 直接查询数据库的性能瓶颈,核心优化在于引入 “先查缓存再查数据库” 的分层查询策略。这一机制可类比为 “图书馆找书先查索引” :缓存如同图书馆的索引目录,能快速定位目标信息(城市列表),只有当索引未命中时,才需前往书架(数据库)查找,从而显著减少直接访问底层存储的频率。
核心实现逻辑解析
V2 版本通过以下步骤实现缓存与数据库的协同查询:
1.缓存查询阶段
首先调用 redisService.getCacheObject(MapConstants.CACHE_MAP_CITY_KEY, new TypeReference<List<SysRegionDTO>>() {})
从 Redis 缓存中获取城市列表数据。此处 MapConstants.CACHE_MAP_CITY_KEY
为缓存键,TypeReference
用于指定泛型类型以确保反序列化准确性。若缓存命中(cache != null
),则直接返回缓存数据,避免数据库访问。
2.缓存未命中处理
当缓存不存在时,执行数据库查询:通过 regionMapper.selectAllRegion()
获取全量区域数据,遍历筛选出 level
为城市级别的记录(MapConstants.CITY_LEVEL
),并通过 BeanUtils.copyProperties
完成 SysRegion
到 SysRegionDTO
的对象转换,最终构建结果列表 result
。
3.缓存回写机制
数据库查询完成后,调用 redisService.setCacheObject(MapConstants.CACHE_MAP_CITY_KEY, result)
将结果写入缓存,确保后续请求可直接命中缓存。
关键代码片段
// 查询缓存
List cache = redisService.getCacheObject(MapConstants.CACHE_MAP_CITY_KEY, new TypeReference>() {});
if (cache != null){ return cache; // 缓存命中,直接返回
}
// 缓存未命中,查数据库并转换对象
List list = regionMapper.selectAllRegion();
// ... 对象转换逻辑 ...
// 回写缓存
redisService.setCacheObject(MapConstants.CACHE_MAP_CITY_KEY, result);
性能改进与潜在风险
性能提升:通过缓存复用热点数据,V2 版本将查询响应时间从 V1 的数百毫秒级降至 100 ms 以内,大幅减少了数据库 IO 压力。
新问题分析:
- 缓存穿透风险:若数据库查询结果为空(如无符合条件的城市数据),当前逻辑未将空结果写入缓存,导致后续请求仍会穿透至数据库,形成无效查询。
- 缓存雪崩隐患:缓存未设置过期时间(TTL),一旦缓存服务异常或缓存键被批量删除,大量请求将瞬间涌入数据库,可能引发服务雪崩。
查询流程示意图
请求 → 查 Redis 缓存 ├─ 命中 → 返回结果 └─ 未命中 → 查数据库 → 结果写入缓存 → 返回结果
上述设计为后续引入二级缓存(如本地缓存)及缓存防护机制(如过期时间、空值缓存)奠定了基础。
2.3二级缓存方案
在快递运输场景中,单个超大包裹因体积限制难以拆分运输,类似地,缓存系统中的 BigKey 问题也面临类似挑战。BigKey 定义为每次查询 Redis 报文大于 10K 或记录数较多的数据。对于城市列表查询场景,由于业务要求必须整体返回完整列表数据,传统的 BigKey 拆分策略(如按区域分片存储)会导致查询时需聚合多个分片数据,反而增加网络开销和响应延迟,因此拆分方案在此场景下不可行。为解决此问题,V3 版本引入二级缓存架构:以 Caffeine 作为本地缓存(一级缓存),Redis 作为分布式缓存(二级缓存),通过双层缓存协同提升查询性能。
二级缓存核心设计:本地缓存(Caffeine)利用内存访问速度优势处理高频查询,Redis 则作为分布式缓存保障集群缓存一致性,二者形成互补层级。
V3 版本核心实现如下,通过 CacheUtil 工具类协调本地缓存与 Redis 的查询和更新流程:
/** * 城市列表查询 V3 二级缓存方案 * @return 城市列表信息 */
public List<SysRegionDTO> getCityListV3() { List<SysRegionDTO> result = new ArrayList<>(); // 二级缓存查询:先查本地Caffeine,再查RedisList<SysRegionDTO> cache = CacheUtil.getL2Cache(redisService, MapConstants.CACHE_MAP_CITY_KEY, new TypeReference<List<SysRegionDTO>>() {}, caffeineCache); if(cache != null){ return cache; } // 缓存未命中时查询数据库List<SysRegion> list = regionMapper.selectAllRegion(); for (SysRegion sysRegion : list) { if (sysRegion.getLevel().equals(MapConstants.CITY_LEVEL)){ SysRegionDTO sysRegionDTO = new SysRegionDTO(); BeanUtils.copyProperties(sysRegion, sysRegionDTO); result.add(sysRegionDTO); } } // 双写缓存:同时更新本地缓存与RedisCacheUtil.setL2Cache(redisService, MapConstants.CACHE_MAP_CITY_KEY, result, caffeineCache, 120L, TimeUnit.MINUTES); return result;
}
CacheUtil 工具类的核心方法逻辑如下:
- getL2Cache():采用"本地缓存优先"策略,首先检查 Caffeine 本地缓存,命中则直接返回结果;未命中则查询 Redis,若 Redis 存在数据则返回结果并同步至本地缓存;若 Redis 也未命中,则返回 null 触发数据库查询流程。
- setL2Cache():实现"双写一致性"机制,在数据更新时,先写入 Caffeine 本地缓存(设置与 Redis 一致的过期时间),再通过 Redis 客户端写入分布式缓存,确保两级缓存数据同步。
相较于 V1(纯数据库查询)和 V2(单一 Redis 缓存),V3 方案通过本地缓存减少分布式缓存访问次数,显著降低响应时间并提高缓存命中率。其查询流程可概括为:本地缓存命中 → 直接返回;本地未命中 → 查询 Redis → Redis 命中返回并更新本地缓存;Redis 未命中 → 查询数据库 → 回写 Redis 与本地缓存后返回。这一架构既解决了 BigKey 带来的网络传输压力,又通过本地缓存提升了查询效率。
2.4缓存预热
缓存预热机制可类比为演唱会开场前的提前检票流程:通过在服务启动阶段主动加载热点数据到缓存系统,避免用户请求高峰期因缓存未命中导致的"流量拥堵"。在城市列表查询场景中,服务启动初期若缓存为空,大量并发请求会直接穿透至数据库,引发缓存击穿风险,可能导致数据库连接池耗尽或查询延迟剧增。
启动时预热实现方案
为解决上述问题,项目采用服务启动阶段主动预热策略,核心实现基于 @PostConstruct
注解与两级缓存协同加载机制。具体流程如下:
1.初始化触发机制
通过 @PostConstruct
注解标记 initCityMap()
方法,确保其在 Spring 容器初始化完成后自动执行。该注解的作用类似于服务启动的"启动钩子",无需外部触发即可完成缓存预热。
2.数据加载与筛选
initCityMap()
方法首先调用 regionMapper.selectAllRegion()
从数据库全量查询行政区域数据,随后通过 loadCityInfo()
方法筛选出市级数据(level
字段等于 MapConstants.CITY_LEVEL
),并转换为 SysRegionDTO
数据传输对象。
3.两级缓存协同预热
筛选后的城市数据通过 CacheUtil.setL2Cache()
方法同步写入本地缓存(Caffeine) 与分布式缓存(Redis)。该工具方法需传入 Redis 服务实例、缓存键(MapConstants.CACHE_MAP_CITY_KEY
)、数据对象、Caffeine 缓存实例及过期时间参数,实现两级缓存的原子性预热。
核心实现代码如下:
@PostConstruct
public void initCityMap(){ // 从数据库全量查询行政区域数据List<SysRegion> list = regionMapper.selectAllRegion(); // 筛选并缓存城市列表loadCityInfo(list);
} /** * 筛选市级数据并预热两级缓存* @param list 全量行政区域数据*/
private void loadCityInfo(List<SysRegion> list) { List<SysRegionDTO> result = new ArrayList<>(); for (SysRegion sysRegion : list) { // 筛选市级行政单位if (sysRegion.getLevel().equals(MapConstants.CITY_LEVEL)){ SysRegionDTO sysRegionDTO = new SysRegionDTO(); BeanUtils.copyProperties(sysRegion, sysRegionDTO); result.add(sysRegionDTO); } // 同步预热本地缓存与 RedisCacheUtil.setL2Cache(redisService, MapConstants.CACHE_MAP_CITY_KEY, result, caffeineCache, 120L, TimeUnit.MINUTES); }
}
关键注意事项
缓存预热实施过程中需重点关注过期时间设置引发的二次击穿风险:若为缓存数据设置固定过期时间(如代码中的 120 分钟),到期后缓存失效可能导致新一轮请求穿透至数据库。解决方案有两种:
缓存有效期优化策略
- 永久缓存方案:移除过期时间设置,将缓存标记为永久有效,适用于城市数据变更频率极低的场景。
- 定时刷新机制:保留较短过期时间(如 30 分钟),同时通过
@Scheduled
注解配置定时任务,在缓存过期前主动刷新数据,避免缓存真空期。
通过上述机制,可确保城市列表数据在服务全生命周期内的缓存可用性,将数据库查询压力降至最低。
3.小结
技术优化是一个螺旋上升的迭代过程,城市列表查询方案的演进清晰展现了这一规律。V1 版本通过直连数据库的实现,直接暴露了高并发场景下的数据库性能瓶颈;V2 版本引入缓存基础方案初步缓解了数据库压力,但未解决数据体量与访问效率的深层矛盾;V3 版本针对 BigKey 问题进行专项优化,通过数据分片与结构调整突破性能瓶颈;V4 版本则通过缓存预热机制,从根本上保障了缓存系统的可用性,形成完整的缓存防护体系。
核心启示:技术优化不存在"银弹",需根据业务场景动态选择方案。对于低频变动数据(如城市列表),二级缓存+预热的组合能同时兼顾性能与可用性,是经过实践验证的高效解决方案。
未来优化可向两个方向深化:一是探索更精细化的缓存更新策略(如基于变更频率的分级更新机制),二是研究分布式环境下的缓存一致性保障(如结合分布式锁与版本号的同步方案),持续构建更具弹性的技术架构。