地图城市缓存优化:ApplicationReadyEvent + Caffeine + Redis + Redisson 分布式锁

一、背景

在地图业务中,城市列表、行政区划、城市拼音分组等数据通常具有两个特点:

  • 查询频率高
  • 数据变化频率低

例如用户每次打开地图页面、城市选择器时,都可能查询城市列表。

如果每次请求都直接访问数据库:

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 失效导致的缓存击穿问题。

相关推荐
2601_962175662 小时前
RabbitMQ 的工作模式
分布式·rabbitmq
爱和冰阔落3 小时前
【Linux】多线程打印为什么会乱?pthread 创建、等待、退出、取消与分离全实战
linux·运维·c++·redis
头茬韭菜3 小时前
图解 Fluss(四):分布式协调 —— 选举、副本状态机与
分布式·fluss
一只小bit5 小时前
Redis哨兵机制、主从复制与集群搭建与维护
数据库·redis·bootstrap
智购科技无人售货机厂家12 小时前
2026自动售货机制冷系统维护指南:从散热器清洁到压缩机换油的工程实践~YH
运维·redis·物联网·缓存·架构
2601_9621741716 小时前
Redis 简介
数据库·redis·wpf
2601_9621284116 小时前
SpringBoot篇(缓存层)
java·spring boot·缓存
mmsy102418 小时前
缓存知识点总结
java·spring·缓存
garmin Chen20 小时前
redis面试题
java·数据库·redis·缓存