分布式缓存:这次怎么落地的
服务从单实例扩到 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
这张图简单展示了 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。