DoorDash 如何用 Envoy 与 Valkey构建 150 万 RPS 的透明代理缓存
在微服务系统中,同一份实体数据往往会被多个服务反复读取。即使单次 RPC 很快,重复请求叠加后仍会消耗网络、连接池和下游计算资源。DoorDash 为此构建了 Entity Cache:一个运行在服务网格中的透明代理缓存平台,以 Envoy 承接服务间流量,以 Valkey 保存缓存数据,并通过事件驱动失效、故障处理和性能优化,将整体规模推进到每秒超过 150 万次请求,同时达到 99.99999% 的可用性。
把缓存放进代理层,而不是每个业务服务
传统做法是在应用代码里引入缓存客户端:先查缓存,未命中时请求下游,再写回缓存。这个模式容易落地,但会把键设计、序列化、超时、失效和降级逻辑复制到大量服务中。
透明代理缓存改变了责任边界。调用方仍然发送普通的服务间请求,Envoy 在流量路径上识别可缓存请求,并决定返回缓存结果还是转发到源服务。应用不需要知道缓存是否存在。
一条典型读取链路可以抽象为:
Caller
-> Envoy proxy
-> cache hit: return cached entity
-> cache miss: call origin service
-> return response
-> populate Valkey
这种设计的直接收益不是少写几行缓存代码,而是把缓存策略变成平台能力:
- 可以统一控制哪些接口允许缓存,以及缓存键包含哪些字段。
- 可以集中设置超时、容量、指标和故障降级策略。
- 可以让旧服务在少量改造甚至不修改业务代码的情况下接入。
- 可以在服务网格层观察命中率、回源率和缓存附加延迟。
代价也很明确:代理层成为关键基础设施。缓存过滤器的错误可能影响大量服务,因此发布、隔离和回滚机制必须按基础设施级别设计。
Valkey 只是存储,正确性取决于失效链路
实体缓存最棘手的问题通常不是读取速度,而是数据何时过期。固定 TTL 能限制陈旧数据的最长存活时间,却无法保证更新后立即可见。DoorDash 的方案包含事件驱动失效:实体发生变更后,由事件通知缓存删除或更新对应条目。
可以将 TTL 和事件失效看作两道防线:
- 事件失效负责缩短正常情况下的数据陈旧窗口。
- TTL 负责处理事件丢失、消费者暂停或规则配置错误。
缓存键还需要稳定、无歧义。一个可操作的格式是:
entity:{tenant}:{entity_type}:{entity_id}:v{schema_version}
例如:
entity:store-42:menu-item:item-9001:v3
schema_version 很重要。响应结构升级时,与其扫描并删除所有旧值,不如切换到新版本键空间,让旧数据通过 TTL 自然回收。
事件还可能乱序。假设版本 12 的更新事件先到,版本 11 的事件后到,简单执行 DEL 通常仍是安全的,但“用事件内容直接覆盖缓存”可能把新值回退成旧值。如果平台选择主动写入而不是只做失效,就应比较实体版本或更新时间,并通过 Valkey Lua 脚本等原子操作拒绝旧版本。
可以这样实践:验证缓存与失效语义
下面是一个可复制的最小实验。它不是 DoorDash 内部实现,而是用于验证 Valkey 中的 TTL、键命名和失效通知。需要本机安装 Docker。
启动 Valkey:
docker run --rm --name entity-cache-demo \
-p 6379:6379 \
valkey/valkey:8-alpine
在另一个终端写入一个带 60 秒 TTL 的实体,并读取剩余时间:
docker exec entity-cache-demo valkey-cli SET \
'entity:store-42:menu-item:item-9001:v3' \
'{"id":"item-9001","name":"Noodles","version":12}' \
EX 60
docker exec entity-cache-demo valkey-cli GET \
'entity:store-42:menu-item:item-9001:v3'
docker exec entity-cache-demo valkey-cli TTL \
'entity:store-42:menu-item:item-9001:v3'
模拟实体更新事件到达后的失效操作:
docker exec entity-cache-demo valkey-cli DEL \
'entity:store-42:menu-item:item-9001:v3'
生产环境不会依赖人工执行 DEL。可以让变更数据捕获系统或消息消费者接收实体更新事件,再根据实体标识构造缓存键。下面的 YAML 是概念性配置,假设平台已经提供一个名为 entity_cache 的自定义 Envoy HTTP 过滤器;它展示的是策略边界,不是可直接部署的 DoorDash 配置:
static_resources:
listeners:
- name: service_listener
address:
socket_address:
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: entity_proxy
route_config:
name: local_route
virtual_hosts:
- name: entity_api
domains: ["*"]
routes:
- match:
prefix: /v1/menu-items/
route:
cluster: menu_service
http_filters:
- name: entity_cache
typed_config:
"@type": type.example.com/entitycache.v1.Config
key_prefix: "entity:store-42:menu-item"
ttl: 60s
cache_timeout: 5ms
failure_mode_allow: true
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: menu_service
connect_timeout: 250ms
type: STRICT_DNS
load_assignment:
cluster_name: menu_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: menu-service
port_value: 8080
这里最关键的配置不是 TTL,而是 cache_timeout 和 failure_mode_allow:缓存查询必须有很短的时间预算;Valkey 不可用或查询超时时,代理应允许请求绕过缓存并访问源服务。否则,一个用于降低下游负载的组件反而会阻断整个请求路径。
七个九需要端到端故障设计
99.99999% 可用性不能只靠部署更多 Valkey 实例得到。平台需要分别处理缓存、代理、事件系统和源服务的故障。
缓存读取失败时,应快速回源并记录失败类型。缓存写入失败通常不应影响已经成功的业务响应。失效消费者停滞时,平台应通过事件积压量、消费延迟和实体版本差异告警。源服务故障时,是否允许返回短时间过期的数据,则要由业务一致性要求决定。
高吞吐下还要防止缓存击穿。热门键过期时,大量请求可能同时回源。工程上可以采用请求合并、TTL 抖动、后台刷新或短时负缓存,但每种方法都有边界:负缓存可能隐藏刚创建的数据,后台刷新会增加系统复杂度,而过长的过期数据窗口可能违反业务语义。
建议至少持续观察以下指标:
- 按接口、实体类型和调用方拆分的缓存命中率。
- 缓存查询延迟,以及它在端到端延迟中的占比。
- 回源请求量、请求合并比例和热点键分布。
- Valkey 超时、连接错误、驱逐和内存使用量。
- 失效事件的消费延迟、失败次数和积压深度。
- 缓存值版本与源数据版本不一致的抽样结果。
接入时从低风险读取接口开始
透明代理缓存适合读取频繁、响应可按稳定键定位、并且能够容忍明确陈旧窗口的实体接口。支付状态、权限决策或强一致库存等接口不能仅凭高命中率就接入,必须先定义可接受的一致性模型。
落地时可以先选择一个幂等读取接口,以影子模式计算缓存键和命中率,但仍然返回源服务结果;随后比较缓存值与源响应,验证失效链路;再逐步放量,并保留按服务、路由和实体类型关闭缓存的开关。
DoorDash 的案例说明,Envoy 与 Valkey 的组合能够把服务间重复读取从应用问题提升为平台问题。真正决定系统能否达到大规模和高可用的,并不是某一次快速的缓存命中,而是缓存失效时能否保持正确、缓存故障时能否快速绕过,以及平台是否具备足够细粒度的观测与回滚能力。