一、背景
在地图业务中,城市列表、行政区划、城市拼音分组等数据通常具有两个特点:
- 查询频率高
- 数据变化频率低
例如用户每次打开地图页面、城市选择器时,都可能查询城市列表。
如果每次请求都直接访问数据库:
java
List<SysRegion> list = regionMapper.selectAllRegion();
那么在高并发情况下,大量请求都会落到 MySQL:
text
请求1 ──→ MySQL
请求2 ──→ MySQL
请求3 ──→ MySQL
请求4 ──→ MySQL
...
请求1000 ──→ MySQL
但实际上,全国城市数据并不会频繁发生变化。因此,这类数据非常适合使用缓存进行优化。最终的优化方案是:
text
ApplicationReadyEvent 启动预热
+
Caffeine 本地缓存
+
Redis 分布式缓存
+
Redisson 分布式锁
+
Double Check 双重检查
核心目标是:
尽量让请求从缓存返回,同时在缓存失效时防止大量请求同时查询数据库。
二、优化后的整体架构
整套城市缓存优化最终可以压缩成下面这张流程图:
text
应用启动完成
│
↓
ApplicationReadyEvent
│
↓
预热城市缓存
│
↓
┌──────────────────┐
│ Caffeine 是否命中 │
└────────┬─────────┘
│否
↓
┌──────────────────┐
│ Redis 是否命中 │
└────────┬─────────┘
│否
↓
┌──────────────────┐
│ 获取 Redisson 锁 │
└────────┬─────────┘
↓
再次检查两级缓存
/ \
命中 未命中
│ │
↓ ↓
返回 MySQL
│
↓
查询行政区划数据
│
↓
构建城市列表
/ \
↓ ↓
城市列表缓存 拼音分组缓存
\ /
↓ ↓
Redis + Caffeine
│
↓
返回
这一套流程解决的其实主要是三个问题:
text
数据库压力
Redis 网络访问压力
缓存失效时的并发击穿问题
第一步:应用启动完成后预热缓存
原来的缓存初始化如果放在:
java
@PostConstruct
那么它会发生在 Spring Bean 初始化阶段。
例如:
text
Spring 创建 Bean
↓
依赖注入
↓
@PostConstruct
↓
查询 MySQL
↓
访问 Redis
↓
初始化缓存
↓
Bean 创建完成
如果数据库或者 Redis 响应比较慢,那么 Bean 初始化过程就会被阻塞。
因此这里改成:
java
@EventListener(ApplicationReadyEvent.class)
public void initCityMap() {
try {
reloadCityCacheWithLock();
} catch (Exception ex) {
log.warn("Init city cache failed", ex);
}
}
ApplicationReadyEvent 表示:
Spring Boot 应用已经完成启动。
整体流程变成:
text
Spring Bean 初始化
↓
Spring 容器启动完成
↓
Web Server 启动完成
↓
ApplicationReadyEvent
↓
预热城市缓存
这样做的第一个优势就是:
将缓存预热从 Bean 初始化流程中移出,降低缓存初始化对 Spring Bean 创建过程的影响。
同时这里还增加了:
java
try {
reloadCityCacheWithLock();
} catch (Exception ex) {
log.warn("Init city cache failed", ex);
}
即使预热失败,也不会因为缓存初始化失败直接导致整个应用启动失败。
第二步:Caffeine + Redis 两级缓存
查询城市列表时,首先访问缓存:
java
@Override
public List<SysRegionDTO> getCityList() {
List<SysRegionDTO> cache = getCityListCache();
if (cache != null) {
return cache;
}
log.info("City list cache missed, reload from database");
return reloadCityCacheWithLock();
}
其中:
java
private List<SysRegionDTO> getCityListCache() {
return CacheUtil.getL2Cache(
redisService,
mapCacheProperties.getCityListKey(),
new TypeReference<List<SysRegionDTO>>() {
},
caffeineCache);
}
这里实际上使用的是:
text
L1:Caffeine
L2:Redis
查询顺序:
text
请求
↓
Caffeine
↓
未命中
↓
Redis
↓
未命中
↓
MySQL
为什么已经有 Redis,还要使用 Caffeine?
因为 Redis 虽然很快,但仍然存在网络访问。
Redis:
text
Java 应用
↓
网络请求
↓
Redis Server
↓
序列化 / 反序列化
↓
返回 Java 应用
而 Caffeine 是 JVM 本地缓存:
text
Java 应用
↓
JVM 内存
↓
直接读取
因此速度通常:
text
Caffeine
>
Redis
>
MySQL
对于城市列表这种热点数据:
text
城市列表
行政区划
字典数据
配置信息
非常适合使用:
text
Caffeine + Redis
两级缓存。
第三步:为什么需要 Redisson 分布式锁?
这里才是整个缓存优化中最重要的一步。
假设城市列表缓存突然过期。
此时正好有 1000 个请求同时访问:
text
缓存失效
请求1 → 缓存 miss
请求2 → 缓存 miss
请求3 → 缓存 miss
请求4 → 缓存 miss
...
请求1000 → 缓存 miss
如果没有任何保护措施,这 1000 个请求全部都会执行:
java
regionMapper.selectAllRegion();
最终:
text
1000 个请求
↓
1000 次数据库查询
这就是典型的:
缓存击穿。
所以需要保证:
缓存失效之后,只允许一个请求去查询数据库并重建缓存。
于是引入 Redisson 分布式锁。
java
RedissonLockService lockService =
redissonLockServiceProvider.getIfAvailable();
if (lockService != null) {
lock = lockService.acquire(
mapCacheProperties.getCityReloadLockKey(),
mapCacheProperties.getCityReloadLockExpireMillis());
}
有了分布式锁之后:
text
1000 个请求
↓
全部缓存 miss
↓
竞争 Redis 分布式锁
↓
请求 A 获得锁
↓
查询 MySQL
↓
重建 Redis
↓
重建 Caffeine
↓
释放锁
其他请求:
text
B ─┐
C ─┤
D ─┤
E ─┤ → 等待
F ─┘
于是数据库不会瞬间被大量请求打满。
第四步:锁里面为什么还要再次检查缓存?
这是整个设计里面非常关键的一个细节。
完整代码:
java
private List<SysRegionDTO> reloadCityCacheWithLock() {
RedissonLockService lockService =
redissonLockServiceProvider.getIfAvailable();
RLock lock = null;
try {
if (lockService != null) {
lock = lockService.acquire(
mapCacheProperties.getCityReloadLockKey(),
mapCacheProperties.getCityReloadLockExpireMillis());
}
List<SysRegionDTO> cache = getCityListCache();
if (cache != null) {
return cache;
}
List<SysRegion> list = regionMapper.selectAllRegion();
List<SysRegionDTO> cityList = buildCityList(list);
loadCityInfo(cityList);
loadCityPinyinInfo(cityList);
return cityList;
} finally {
if (lockService != null) {
lockService.releaseLock(lock);
}
}
}
很多人第一次看到都会有一个疑问:前面的:
java
getCityList()
不是已经检查过缓存了吗?为什么获得锁之后:
java
List<SysRegionDTO> cache = getCityListCache();
if (cache != null) {
return cache;
}
还要检查一次?原因是:
获取分布式锁存在等待过程。
假设 A 和 B 同时访问。一开始:
text
A:缓存 miss
B:缓存 miss
然后:
text
A:获得锁
B:等待锁
A 开始查询数据库:
text
A
↓
查询 MySQL
↓
写 Redis
↓
写 Caffeine
↓
释放锁
此时 Redis 里面已经有数据了。
然后 B 获取到锁。如果 B 获取锁之后直接查询数据库:
text
B
↓
获得锁
↓
查询 MySQL
那么这次数据库访问其实完全是多余的。
因此 B 获得锁以后必须:
text
再次检查缓存
流程变成:
text
B
↓
获得锁
↓
再次检查 Redis / Caffeine
↓
发现 A 已经写入缓存
↓
直接返回
最终就可以做到:
text
A → 查询数据库
B → 使用缓存
C → 使用缓存
D → 使用缓存
E → 使用缓存
数据库只查询一次。这种方式称为:
Double Check,双重检查。
为什么不能直接使用 synchronized?
有人可能会想到:
java
synchronized
不也可以让多个线程排队吗?
如果只有单机部署:
text
JVM
↓
多个线程
确实可以。
但是微服务一般可能部署多个实例:
text
Nginx
│
┌──────┴──────┐
↓ ↓
服务实例 A 服务实例 B
JVM A JVM B
如果使用:
java
synchronized
实际上是:
text
服务 A
↓
JVM A 的锁
服务 B
↓
JVM B 的锁
两个 JVM 完全不知道对方的锁。
因此可能出现:
text
实例 A
↓
获得 JVM A 锁
↓
查询 MySQL
实例 B
↓
获得 JVM B 锁
↓
查询 MySQL
数据库依然会被多个实例同时访问。
而 Redisson 的锁放在 Redis 中:
text
Redis
│
city:reload:lock
/ \
/ \
服务 A 服务 B
所有服务实例竞争的是:
text
同一把锁
所以:
text
synchronized
解决的是:
单 JVM 并发。
而:
text
Redisson 分布式锁
解决的是:
多实例分布式并发。
第五步:一次查询数据库,同时生成多份缓存
获得锁并确认缓存不存在以后:
java
List<SysRegion> list = regionMapper.selectAllRegion();
List<SysRegionDTO> cityList = buildCityList(list);
loadCityInfo(cityList);
loadCityPinyinInfo(cityList);
这里只查询了一次数据库:
java
regionMapper.selectAllRegion();
然后基于这一份数据分别生成:
text
城市列表缓存
+
拼音分组缓存
整体流程:
text
MySQL
│
↓
selectAllRegion()
│
↓
cityList
/ \
↓ ↓
城市列表缓存 拼音分组缓存
\ /
↓ ↓
Redis + Caffeine
而不是:
text
查询一次数据库
↓
生成城市列表
再查询一次数据库
↓
生成拼音分组
因此这种方式本质上属于:
一次数据加载,多种缓存结果复用。
减少了数据库 IO。
其他小优化1:提前计算拼音分组
这里还有一个很容易忽略的优化:
java
private void loadCityPinyinInfo(List<SysRegionDTO> cityList) {
Map<String, List<SysRegionDTO>> result =
new LinkedHashMap<>();
for (SysRegionDTO city : cityList) {
String groupKey =
getPinyinGroupKey(city.getPinyin());
result.computeIfAbsent(
groupKey,
key -> new ArrayList<>()
).add(city);
}
CacheUtil.setL2Cache(
redisService,
mapCacheProperties.getCityPinyinKey(),
result,
caffeineCache,
mapCacheProperties.getCityTimeout(),
mapCacheProperties.getCityTimeoutUnit());
}
如果不缓存拼音分组,每次请求都需要:
text
读取城市列表
↓
遍历所有城市
↓
读取拼音
↓
截取首字母
↓
转大写
↓
分组
↓
返回
如果有 10000 个请求:
text
分组逻辑执行 10000 次
现在:
text
缓存初始化 / 重建
↓
执行一次拼音分组
↓
保存到缓存
↓
后续请求直接读取
属于典型的:
以空间换时间。
将重复计算提前到缓存构建阶段。
其他小优化2:使用 finally 释放锁
代码:
java
try {
// 查询数据库
// 重建缓存
} finally {
if (lockService != null) {
lockService.releaseLock(lock);
}
}
这是因为获取锁以后,中间任何一步都可能出现异常。
例如:
java
regionMapper.selectAllRegion();
出现数据库异常。
如果写成:
java
lock();
queryDatabase();
unlock();
一旦:
java
queryDatabase();
抛出异常:
text
unlock()
可能永远执行不到。
于是就可能造成:
text
获得锁
↓
发生异常
↓
没有释放锁
↓
其他线程无法继续获得锁
所以必须:
java
try {
...
} finally {
releaseLock();
}
保证无论正常返回还是发生异常,都执行锁释放逻辑。
三、总结
这次城市列表缓存优化,最核心的并不是某一行代码,而是完整的查询链路设计。
可以总结为 5 个关键词:
text
① ApplicationReadyEvent
应用启动完成后进行缓存预热
② Caffeine + Redis
构建 JVM 本地缓存 + 分布式缓存
③ Redisson
使用分布式锁防止缓存击穿
④ Double Check
获取锁以后再次检查缓存,避免重复查询数据库
⑤ 一次查库,多份缓存
一次读取行政区划数据,同时生成城市列表和拼音分组
如果用一句比较适合项目介绍或者面试的话来说:
针对城市行政区划高频读取、低频变化的特点,我设计了 Caffeine + Redis 两级缓存,并在应用启动完成后进行缓存预热;缓存失效时使用 Redisson 分布式锁控制缓存重建,同时通过 Double Check 避免等待锁的请求重复访问数据库,从而降低 Redis 网络访问及 MySQL 查询压力,并防止热点 Key 失效导致的缓存击穿问题。