小Cの已经记不起来的博客

Kubernetes 缓存一致性 配置避坑指南

一开始我还以为是 Redis 的问题

上周把一个配置服务从单副本扩到三副本,接口一上线就发现一个很玄学的问题:同一个配置项,一会儿返回新值,一会儿又返回旧值。
我第一反应是 Redis 没更新,于是去翻 Redis 日志、连接池、过期策略,甚至怀疑是不是网络抖动导致部分 Pod 连到了别的实例。结果忙了一轮,发现 Redis 里其实一直是正确的。

后来才反应过来,问题出在应用自己的本地缓存上。每个 Pod 内部还有一层 Caffeine/Guava 缓存,请求打到不同副本,读到的就是这个 Pod 自己内存里的旧配置。这个坑不大,但真的很阴间,尤其在 Kubernetes 里多副本一上来,很容易让人怀疑人生。

本地缓存不是不能用,但别把它当真相源

很多人写应用的时候喜欢这么配:Redis 做主缓存,进程内再放一层本地缓存提升性能。单副本时这几乎没问题,因为只有一个 JVM,缓存一致性由时间过期控制就凑合过去了。

一到 Kubernetes,多副本部署以后,问题就出来了。某个实例更新了配置,只会清理自己本地的缓存,其他 Pod 的本地缓存根本不会知道。除非你加广播失效机制,否则这层缓存就是不可靠的。

我后来直接把这个开关显式关掉:

env:
  - name: APP_CACHE_LOCAL_ENABLED
    value: "false"
  - name: APP_CACHE_TYPE
    value: "redis"

如果业务真的需要保留本地缓存,那就只缓存静态配置,不缓存会被业务频繁更新的数据。比如一些启动参数、字典、白名单,这类东西过期几十秒也能接受。用户权限、订单状态、实时规则配置这种,就别让 Pod 自己拿内存缓存当准了。

ConfigMap 改了,为什么缓存还没变?

这个坑也很常见。很多项目会把配置塞进 ConfigMap,然后挂载或者注入环境变量。改完 ConfigMap,Pod 里看起来配置已经更新了,但应用还是拿旧值。

原因很直接:大部分进程只在启动时读一次配置。ConfigMap 文件内容可能会被 kubelet 更新,但你的 Java/Go/Python 应用不会自动感知这个变化。环境变量就更别想了,环境变量一旦进入容器进程,ConfigMap 改了它也不会跟着改。

如果你没有上 Reloader 这类工具,最稳的办法还是重启 Deployment:

kubectl rollout restart deployment/config-service

如果有 Reloader,可以加注解自动滚更:

metadata:
  annotations:
    reloader.stakater.com/auto: "true"

我一般会在配置里同时写 Redis key 版本号,不指望 ConfigMap 热更新。ConfigMap 负责告诉应用当前应该读哪个版本,真正的数据版本由 Redis 控制。这样灰度和回滚都更清楚。

一个相对稳的缓存版本方案

如果不想写太重的缓存失效消息队列,可以用一个很轻的 version key 方案。比如更新配置时,不直接覆盖业务 key,而是先写一个新版本:

redis-cli SET app:config:v2:server.port 8080
redis-cli SET app:config:current_version v2

应用读配置时先看 current_version,再拼 key:

app:config:{current_version}:{namespace}:{key}

更新流程大概是:

  1. 写入新版本配置,比如 v2
  2. 确认写入成功
  3. 更新 current_version
  4. 旧版本 key 靠 TTL 慢慢过期

这样多个副本即使本地缓存了配置,也能通过 current_version 发现变化。本地缓存只缓存某个版本,而不是缓存“最新值”。这个思路在 K8s 里特别好用,因为 Pod 数量、滚动升级、短暂双版本并存都是常态,不能指望内存状态永远一致。

滚动升级时,还要给旧 Pod 一点退出时间

这个点很容易被忽略。Kubernetes 删 Pod 之前,会先把它从 Service endpoint 里摘掉。但摘除不是瞬间同步给所有客户端和负载均衡,有时旧 Pod 还能接住几秒请求。

如果这个旧 Pod 里还缓存着旧配置,或者正在处理一个写缓存动作,就可能出现短暂的配置倒退。我后来会在 Deployment 里加一点 preStop 延迟:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 30

别小看这几秒。尤其是网关、sidecar、服务注册中心、客户端重试这些链路,给点缓冲时间会让问题少很多。当然这个 sleep 不是万能药,它只是降低并发窗口,真正的一致性还是要靠缓存版本和失效策略。

验证方式不要只看一个请求返回对不对

当时我最早只 curl 了几次,看到新值就以为修好了。后来才发现这样很危险。多副本下应该压一小轮,把所有返回值去重,看有没有旧值混进来:

for i in $(seq 1 200); do
  curl -s http://config-service/config/version
done | sort -u

或者进 Pod 里看具体哪个副本返回异常:

kubectl get pods -l app=config-service -o custom-columns=NAME:.metadata.name,IP:.status.podIP
kubectl exec -it <pod-name> -- curl -s localhost:8080/config/version

如果 Redis 本身也要看,可以直接查 key:

kubectl exec -it redis-0 -- redis-cli
127.0.0.1:6379> GET app:config:current_version
127.0.0.1:6379> MGET app:config:v1:server.port app:config:v2:server.port

这样能比较快判断到底是应用读错、缓存没更新,还是 ConfigMap 没滚更。

几个别踩的小坑

Redis 的淘汰策略一定要确认清楚。别业务缓存、会话缓存、配置缓存共用一个 Redis,最后 maxmemory-policy 配了 allkeys-lru,一些关键配置 key 被驱逐了都不知道。更稳的是用不同逻辑库或者前缀,配置类 key 尽量用 volatile-lru 或者单独实例。

TTL 也不能随便设。如果你更新配置后只清了一个 key,别的地方还有一层二级缓存,TTL 又很长,问题就会伪装成偶发故障。灰度发布时尤其明显,新版本 Pod 读新 key,旧版本 Pod 还在读旧 key,用户请求一进来就被 Service 随机分配,看起来像系统精神分裂。

还有一点,别把 emptyDir 或者 Pod 本地文件当缓存一致性方案。Pod 一删,文件就没了;Pod 一重建,又可能落在不同节点。它适合临时空间,不适合当业务缓存真相源。

最后总结成一句话

Kubernetes 里最容易出问题的一般不是 Redis 本身,而是你以为只有 Redis,其实应用里还有一层本地缓存;你以为 ConfigMap 改了就生效,其实进程还在用启动时的值;你以为滚动升级很顺滑,其实新旧副本会短暂同时活着。

所以配置缓存的时候,显式关掉本地动态缓存,给 key 加版本号,更新时先写新版本再切 current,给 Pod 退出留一点缓冲,基本就能避开我踩过的那些坑。

不出问题的话就没有问题了,但真出问题的时候,这套思路至少能让排查快很多。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放