分布式缓存:这次怎么落地的

服务从单实例扩到 5 个之后,本地缓存反而把数据库查询次数抬高了。两周改造下来,命中率从 60% 提到 90%,P99 从 800ms 降到 200ms。下面按演进路径记踩坑过程。

起因:本地缓存的局限性

项目初期用 Guava Cache 做本地缓存,代码几行就搞定:

LoadingCache<String, User> userCache = CacheBuilder.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(new CacheLoader<String, User>() {
        @Override
        public User load(String userId) throws Exception {
            return userRepository.findById(userId);
        }
    });

好处明显:快、简单、零网络开销。但问题也显而易见:当服务从 1 个实例扩容到 5 个时,数据库的查询次数反而变多了。原因是每次数据更新后,只有更新请求打到的那台服务器缓存会失效,其他 4 台还在用旧数据。

更麻烦的是,有些计算结果很耗时,比如用户画像的聚合数据。本地缓存意味着每台服务器都要算一遍,既浪费 CPU 又增加数据库压力。而且这些计算结果的时效性要求不高,完全可以共享。

选型:为什么是 Redis 而不是 Memcached

选型时不光看性能,还要看运维成本。Memcached 简单但功能单一,不支持持久化、不支持复杂类型,重启就全没了。Redis 的数据结构更丰富,持久化也相对成熟。而且项目里已经有 Redis 用于会话存储,复用现有基础设施能省不少事。

版本上选了 Redis 7.0,主要看中集群稳定性和内存效率比 6.x 好一截。同样数据集测下来,7.0 内存占用大约少 15%。

从单机到哨兵:先解决高可用

单机 Redis 上线后,业务方反馈不错,但运维团队担心单点故障。于是上了 Redis Sentinel 哨兵模式,配置文件 sentinel.conf

port 26379
sentinel monitor mymaster 192.168.1.10 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 60000

3 个哨兵进程,2 个投票就判定主节点宕机,5 秒内完成故障转移。测试时手动 kill 掉主节点,6 秒左右就完成了切换,应用层连接池自动重连,业务无感知。

但哨兵模式有个问题:数据全写在一台主节点上。当写入量上来后,主节点的 CPU 和带宽压力都很大。而且单节点内存有上限,超过 16GB 后内存碎片会比较严重。

迁移到集群:分片解决写压力

项目到了高峰期,QPS 突破 10 万,单节点的写入能力明显不够用。这时候必须上集群分片。Redis Cluster 的思路是把数据按 key 分到不同的节点上,每个节点只负责一部分。

集群配置采用 6 个节点(3 主 3 从),这样既能分摊写压力,又能在任一主节点宕机时自动切换。启动脚本如下:

# 启动节点 7000-7005
for port in 7000 7001 7002 7003 7004 7005; do
  redis-server --port $port \
    --cluster-enabled yes \
    --cluster-config-file nodes-${port}.conf \
    --cluster-node-timeout 5000 \
    --appendonly yes \
    --appendfilename "appendonly-${port}.aof" \
    --dbfilename dump-${port}.rdb \
    --logfile redis-${port}.log \
    --daemonize yes
done

# 创建集群
redis-cli --cluster create \
  192.168.1.10:7000 192.168.1.10:7001 192.168.1.10:7002 \
  192.168.1.11:7000 192.168.1.11:7001 192.168.1.11:7002 \
  --cluster-replicas 1
graph TD A[应用层] --> B[Redis Cluster Client] B --> C[Key 路由计算] C --> D[节点 7000 主<br/>Slot 0-5460] C --> E[节点 7001 主<br/>Slot 5461-10922] C --> F[节点 7002 主<br/>Slot 10923-16383] D -.-> G[节点 7000 从<br/>节点 7003 主] E -.-> H[节点 7001 从<br/>节点 7004 主] F -.-> I[节点 7002 从<br/>节点 7005 主]

这张图简单展示了 Redis Cluster 的路由逻辑:应用层通过 Cluster Client 计算 key 属于哪个 slot,然后直接连对应的节点。从节点平时不提供读写服务,只在主节点故障时接手。

踩坑一:槽位迁移导致数据丢失

集群上线后不久,有个接口返回空值,查了半天发现是 key 在集群迁移时丢了。原因是槽位迁移过程中,源节点已经迁走了数据,但客户端还在向老节点发起请求,而老节点已经返回了 MOVED 重定向,但客户端缓存没及时更新。

解决方案是在应用层加上重试逻辑,遇到 MOVED 时刷新槽位映射后重试:

public Object get(String key) {
    try {
        return redisTemplate.opsForValue().get(key);
    } catch (RedisMovedException e) {
        // 刷新槽位映射后重试
        clusterTopologyRefresh();
        return redisTemplate.opsForValue().get(key);
    }
}

更稳妥的做法是在数据迁移期间,源节点和目标节点都保留数据副本一段时间,确保迁移完成后再清理源节点的副本。

踩坑二:缓存穿透

上线第一周,监控告警说数据库连接数飙到了 800,正常只有 200。查日志发现大量请求在查询不存在的用户 ID,这些请求绕过了缓存直接打到数据库。

典型的缓存穿透场景:黑客或爬虫用随机 ID 发起请求,缓存里没有,数据库里也没有,每次都要查一遍数据库。

解决方案用布隆过滤器:

// 初始化布隆过滤器
BloomFilter<String> userIdFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000,  // 预期元素数量
    0.01      // 误判率 1%
);

// 用户注册时添加到过滤器
userIdFilter.put(userId);

// 查询前先检查
public User getUser(String userId) {
    if (!userIdFilter.mightContain(userId)) {
        return null;  // 一定不存在,直接返回
    }
    // 先查缓存
    User user = redisTemplate.opsForValue().get("user:" + userId);
    if (user != null) {
        return user;
    }
    // 查数据库
    user = userRepository.findById(userId);
    if (user == null) {
        // 空值也缓存,防止重复查询
        redisTemplate.opsForValue().set("user:" + userId, NULL_VALUE, 5, TimeUnit.MINUTES);
        return null;
    }
    redisTemplate.opsForValue().set("user:" + userId, user, 10, TimeUnit.MINUTES);
    return user;
}

布隆过滤器有一定误判率,但对于缓存穿透场景来说,误判只是多查一次缓存,漏判才是问题。上面的 1% 误判率在实际使用中可以接受。

踩坑三:缓存击穿

某个热点用户上线时,大量粉丝同时访问他的主页,导致该用户的缓存失效瞬间,几千个请求同时打到数据库。这不是穿透(key 不存在),而是击穿(热点 key 过期)。

简单的做法是加分布式锁,但锁的粒度要控制好,锁住整个用户 ID 会影响其他正常的查询。更精细的做法是分段锁:

public User getUser(String userId) {
    String cacheKey = "user:" + userId;
    User user = redisTemplate.opsForValue().get(cacheKey);
    if (user != null) {
        return user;
    }

    // 尝试获取分布式锁
    String lockKey = "lock:user:" + userId;
    boolean locked = false;
    try {
        locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        if (locked) {
            // 获取到锁,查询数据库并重建缓存
            user = userRepository.findById(userId);
            if (user != null) {
                redisTemplate.opsForValue().set(cacheKey, user, 10, TimeUnit.MINUTES);
                // 缓存过期时间稍微随机化,防止同时过期
                long randomExpire = 10 * 60 + (long)(Math.random() * 60);
                redisTemplate.expire(cacheKey, randomExpire, TimeUnit.SECONDS);
            } else {
                redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, 5, TimeUnit.MINUTES);
            }
        } else {
            // 没获取到锁,等待一小段时间后重试
            Thread.sleep(100);
            return getUser(userId);
        }
    } catch (Exception e) {
        log.error("缓存重建失败", e);
    } finally {
        if (locked) {
            redisTemplate.delete(lockKey);
        }
    }
    return user;
}

这里的重点是过期时间加了随机数,防止多个热点 key 同时过期。实测中,这个改动把击穿导致的数据库峰值从 2000 降到了 300 左右。

踩坑四:缓存雪崩

某个版本升级时,为了简化逻辑,把大量缓存 key 的过期时间统一设成了 30 分钟。结果在每天的高峰期前 30 分钟,大量缓存同时失效,导致数据库负载飙升。

雪崩不是单个 key 的问题,而是一批 key 同时过期。除了前面提到的随机化过期时间外,还可以用多级缓存:

public User getUser(String userId) {
    // 一级缓存:本地缓存
    User user = localCache.getIfPresent(userId);
    if (user != null) {
        return user;
    }

    // 二级缓存:分布式缓存
    String cacheKey = "user:" + userId;
    user = redisTemplate.opsForValue().get(cacheKey);
    if (user != null) {
        localCache.put(userId, user);
        return user;
    }

    // 三级:数据库
    user = userRepository.findById(userId);
    if (user != null) {
        redisTemplate.opsForValue().set(cacheKey, user, 10, TimeUnit.MINUTES);
        localCache.put(userId, user);
    }
    return user;
}

本地缓存作为第一道防线,即使分布式缓存全部失效,本地缓存还能抵挡一阵。但要注意本地缓存的容量和一致性,不能什么都放本地。

缓存一致性:最终一致就够了

缓存一致性问题争论很多,但实际业务中,大多数场景只需要最终一致。强一致性带来的性能开销和实现复杂度往往不值得。

我的做法是:先更新数据库,再删除缓存。如果删除失败,通过消息队列异步重试:

@Transactional
public void updateUser(User user) {
    userRepository.update(user);
    // 发送缓存失效消息
    mqProducer.send(new CacheInvalidateMessage("user:" + user.getId()));
}

// 消费者处理缓存失效
@RabbitListener(queues = "cache-invalidate")
public void handleCacheInvalidate(CacheInvalidateMessage message) {
    try {
        redisTemplate.delete(message.getKey());
    } catch (Exception e) {
        // 失败后重试
        mqProducer.retry(message);
    }
}

这样即使第一次删除失败,也会通过重试最终删除。业务上会有短暂的不一致,但通常在秒级内就能恢复。

结果与边界

两周的改造完成后,缓存命中率从 60% 提升到了 90%,数据库连接数从高峰期的 1200 降到了 300 左右,接口响应时间的 P99 从 800ms 降到了 200ms。

但也不是没有问题。集群模式下的事务支持受限,批量操作需要 careful 处理。而且分布式缓存本身也是性能瓶颈,当 QPS 突破 50 万时,Redis 的网络延迟开始影响整体性能。

缓存把压力从数据库挪到了 Redis 集群,QPS 到 50 万之后网络延迟又开始拖后腿。集群模式下事务和批量操作也得额外小心。

选型没有一劳永逸的方案,得跟着业务瓶颈走——我们是从 Guava 本地缓存,到 Sentinel 哨兵,再到 6 节点 Cluster 一步步扩上去的。

可用性说明:本文发布于 2021 年 5 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-分布式缓存:这次怎么落地的https://blog.thinkmoon.cn/post/117-distributed-cache-local-cluster-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!