当酒店价格缓存同时承受更高的读取量和写入量时,继续横向增加关系型数据库分片并不总是最好的答案。Agoda 将约 1.5 TB 的 Price Cache 从 72 个 SQL Server 分片迁移到 DragonflyDB,并通过分阶段双读、数据一致性校验、渐进式流量切换和去中心化故障发现完成迁移。Agoda 报告称,迁移后读路径的 P99 延迟约降低了八倍。
这次改造的关键不只是“换一个更快的缓存产品”,而是把数据验证、回滚和故障处理都纳入了迁移设计。
72 个分片带来的问题,不只是运维复杂度
价格缓存通常有几个鲜明特点:读请求数量大、价格变化会带来持续写入、数据有明确的过期时间,而且同一个酒店和入住条件可能被大量请求重复访问。原有架构将缓存分布在 72 个 SQL Server 分片上,随着读写规模增长,系统需要同时面对数据库连接、分片路由、热点键、复制以及故障切换等问题。
分片可以扩大容量和吞吐,但它也把系统复杂度扩散到了应用层。应用不仅要知道如何定位分片,还要处理某个分片变慢、不可用或数据落后的情况。对于价格缓存这样的高频读路径,额外的路由和数据库开销会直接反映在尾延迟上。
DragonflyDB 被选作新的缓存存储后,Agoda 使用两个集群提供高可用能力。这里的重点不是集群数量本身,而是将缓存读写从原有的 72 个 SQL Server 分片中解耦出来,让迁移后的访问路径更适合高并发缓存场景。
迁移的核心:先证明一致,再移动流量
直接把所有请求切到新缓存,会把“存储迁移”和“业务故障”混在一起。一旦出现价格不一致,很难判断问题来自回填、序列化、过期策略、分片路由,还是新集群本身。Agoda 采用了更稳妥的阶段化路径:
- 双读:同一个请求同时读取旧缓存和 DragonflyDB,但先继续使用旧缓存的结果。
- 一致性校验:比较两个系统的命中情况、价格内容、版本或过期信息,并记录差异。
- 小比例切流:在校验结果达到预期后,只让一小部分请求使用新缓存。
- 逐步扩大流量:持续观察 P50、P95、P99、错误率、命中率和写入延迟,再扩大比例。
- 保留回滚开关:任何阶段发现异常,都能将读流量切回旧系统。
双读并不意味着简单地把两次查询结果丢弃。一个可用的校验器需要区分“新缓存未命中”和“新缓存返回了错误价格”,也需要考虑字段顺序、数值精度、压缩格式和过期时间差异。价格缓存的比较规则应该由业务语义定义,而不是直接比较两个序列化后的字符串。
一个可改造的渐进式切流示例
下面是一个使用 Redis 兼容协议的 Python 示例。它假设旧缓存和 DragonflyDB 都能通过 Redis 客户端访问,并且缓存值是 JSON。示例中的双读、采样校验和按比例切流逻辑可以作为迁移脚手架;实际接入时,需要替换连接地址、键格式、超时和业务字段比较规则。
运行前安装依赖:
pip install redis
import json
import os
import random
from concurrent.futures import ThreadPoolExecutor
from redis import Redis
OLD_REDIS_URL = os.getenv("OLD_REDIS_URL", "redis://127.0.0.1:6379/0")
NEW_REDIS_URL = os.getenv("DRAGONFLY_URL", "redis://127.0.0.1:6380/0")
NEW_READ_PERCENT = int(os.getenv("NEW_READ_PERCENT", "0"))
PARITY_SAMPLE_PERCENT = int(os.getenv("PARITY_SAMPLE_PERCENT", "1"))
old_cache = Redis.from_url(OLD_REDIS_URL, decode_responses=True, socket_timeout=0.05)
new_cache = Redis.from_url(NEW_REDIS_URL, decode_responses=True, socket_timeout=0.05)
executor = ThreadPoolExecutor(max_workers=8)
def normalize(value):
if value is None:
return None
data = json.loads(value)
# 只比较业务需要的字段;不要把序列化顺序当成数据差异。
return {
"hotel_id": data.get("hotel_id"),
"currency": data.get("currency"),
"total_price": round(float(data["total_price"]), 2),
"rooms": data.get("rooms"),
}
def get_price(cache, key):
return cache.get(key)
def read_price(hotel_id, check_in, check_out):
key = f"price:{hotel_id}:{check_in}:{check_out}"
old_future = executor.submit(get_price, old_cache, key)
new_future = executor.submit(get_price, new_cache, key)
old_value = old_future.result()
new_value = new_future.result()
if random.randrange(100) < PARITY_SAMPLE_PERCENT:
if normalize(old_value) != normalize(new_value):
print(f"parity_mismatch key={key}")
# 生产环境应发送带采样率、版本和请求 ID 的指标或事件。
if random.randrange(100) < NEW_READ_PERCENT and new_value is not None:
return json.loads(new_value)
return json.loads(old_value) if old_value is not None else None
if __name__ == "__main__":
print(read_price("hotel-123", "2025-07-01", "2025-07-03"))
这个示例故意在新缓存未命中时回退到旧缓存,避免迁移早期因为回填不完整而放大缓存未命中。正式上线前还应补充以下保护:
- 为旧、新缓存分别设置连接池和超时,避免双读拖慢主请求线程。
- 记录旧值命中、新值命中、新值错误、校验不一致和回退次数。
- 对敏感价格字段做脱敏或哈希,避免校验日志泄露业务数据。
- 明确写入顺序:通常先写新缓存,再写旧缓存,或通过可靠事件重放保证最终一致。
- 设计空值、过期键和删除操作的传播规则,避免旧缓存重新“复活”过期价格。
两个集群与去中心化故障发现
Agoda 使用两个 DragonflyDB 集群提供高可用,并采用去中心化的故障发现方式。对于高并发价格服务,这种思路的价值在于:故障检测不必完全依赖一个中心协调组件,应用或服务节点可以根据本地观测到的超时、连接错误和健康检查结果采取动作。
但“去中心化”不等于“每个实例随意切换”。需要提前定义清晰的边界:
- 哪些错误允许快速切换,哪些错误必须重试或报警?
- 切换到另一集群后,是否保证最新价格,还是接受短时间旧数据?
- 两个集群之间如何同步写入、处理冲突和传播删除?
- 故障恢复后,如何避免流量瞬间全部回切造成惊群?
可以把故障处理拆成三层:客户端短超时与有限重试、服务级别的集群降级开关、平台级别的健康状态和告警。重试必须带退避和上限,否则在缓存集群故障时,应用重试会把流量变成更大的压力。
延迟降低八倍之后,还要看什么
P99 读延迟约降低八倍是重要结果,但它不应成为唯一验收指标。缓存迁移至少要同时观察:
- 正确性:价格、币种、房型、库存和取消规则等关键字段的不一致率。
- 可用性:缓存集群错误率、连接失败率、超时率以及回退成功率。
- 容量:内存使用、键数量、单节点负载和热点键分布。
- 新鲜度:写入延迟、过期延迟和旧值存活时间。
- 业务结果:搜索响应时间、预订转化率和价格投诉,而不只是基础设施指标。
尾延迟优化尤其容易被平均值掩盖。一个平均响应时间很漂亮的系统,可能仍有少数用户持续遇到超时。因此切流阶段应把 P99、超时和回退率作为硬门槛,并按地域、酒店类型、请求来源和流量比例拆分指标。
给类似迁移项目的落地清单
如果你也要把大型 SQL 缓存迁移到 Redis 兼容的内存数据库,可以按下面的顺序推进:
- 盘点键空间、值大小、TTL、读写比例和热点分布。
- 用真实流量或脱敏回放测试序列化、过期和峰值吞吐。
- 先建立双写或回填能力,再上线双读和一致性指标。
- 将切流比例、回滚开关和故障转移策略配置化。
- 从低风险流量开始,按指标逐级放大,而不是按时间表强行切换。
- 在旧系统下线前保留完整的回退路径和数据清理方案。
这次迁移最值得借鉴的地方,是把数据库替换当成一个可观测、可回滚的演进过程。DragonflyDB 可能适合高吞吐、低延迟的缓存读写,但最终收益仍取决于键设计、TTL、写入一致性、故障策略和流量治理。产品替换只是起点,验证和渐进式切换才是降低风险的核心。