Agoda 如何用 DragonflyDB 重构 1.5 TB 酒店价格缓存

2026-09-14 29 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

当酒店价格缓存同时承受更高的读取量和写入量时,继续横向增加关系型数据库分片并不总是最好的答案。Agoda 将约 1.5 TB 的 Price Cache 从 72 个 SQL Server 分片迁移到 DragonflyDB,并通过分阶段双读、数据一致性校验、渐进式流量切换和去中心化故障发现完成迁移。Agoda 报告称,迁移后读路径的 P99 延迟约降低了八倍。

这次改造的关键不只是“换一个更快的缓存产品”,而是把数据验证、回滚和故障处理都纳入了迁移设计。

72 个分片带来的问题,不只是运维复杂度

价格缓存通常有几个鲜明特点:读请求数量大、价格变化会带来持续写入、数据有明确的过期时间,而且同一个酒店和入住条件可能被大量请求重复访问。原有架构将缓存分布在 72 个 SQL Server 分片上,随着读写规模增长,系统需要同时面对数据库连接、分片路由、热点键、复制以及故障切换等问题。

分片可以扩大容量和吞吐,但它也把系统复杂度扩散到了应用层。应用不仅要知道如何定位分片,还要处理某个分片变慢、不可用或数据落后的情况。对于价格缓存这样的高频读路径,额外的路由和数据库开销会直接反映在尾延迟上。

DragonflyDB 被选作新的缓存存储后,Agoda 使用两个集群提供高可用能力。这里的重点不是集群数量本身,而是将缓存读写从原有的 72 个 SQL Server 分片中解耦出来,让迁移后的访问路径更适合高并发缓存场景。

迁移的核心:先证明一致,再移动流量

直接把所有请求切到新缓存,会把“存储迁移”和“业务故障”混在一起。一旦出现价格不一致,很难判断问题来自回填、序列化、过期策略、分片路由,还是新集群本身。Agoda 采用了更稳妥的阶段化路径:

  1. 双读:同一个请求同时读取旧缓存和 DragonflyDB,但先继续使用旧缓存的结果。
  2. 一致性校验:比较两个系统的命中情况、价格内容、版本或过期信息,并记录差异。
  3. 小比例切流:在校验结果达到预期后,只让一小部分请求使用新缓存。
  4. 逐步扩大流量:持续观察 P50、P95、P99、错误率、命中率和写入延迟,再扩大比例。
  5. 保留回滚开关:任何阶段发现异常,都能将读流量切回旧系统。

双读并不意味着简单地把两次查询结果丢弃。一个可用的校验器需要区分“新缓存未命中”和“新缓存返回了错误价格”,也需要考虑字段顺序、数值精度、压缩格式和过期时间差异。价格缓存的比较规则应该由业务语义定义,而不是直接比较两个序列化后的字符串。

一个可改造的渐进式切流示例

下面是一个使用 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 兼容的内存数据库,可以按下面的顺序推进:

  1. 盘点键空间、值大小、TTL、读写比例和热点分布。
  2. 用真实流量或脱敏回放测试序列化、过期和峰值吞吐。
  3. 先建立双写或回填能力,再上线双读和一致性指标。
  4. 将切流比例、回滚开关和故障转移策略配置化。
  5. 从低风险流量开始,按指标逐级放大,而不是按时间表强行切换。
  6. 在旧系统下线前保留完整的回退路径和数据清理方案。

这次迁移最值得借鉴的地方,是把数据库替换当成一个可观测、可回滚的演进过程。DragonflyDB 可能适合高吞吐、低延迟的缓存读写,但最终收益仍取决于键设计、TTL、写入一致性、故障策略和流量治理。产品替换只是起点,验证和渐进式切换才是降低风险的核心。


相关推荐