← 文章 / 数据与数据库
usefathom 1小时前 · 2026-09-12 10:18:06 · 1 阅读

两次数据库迁移与一次“分手”:重构 Fathom 分析引擎

我们如何重建 Fathom 分析引擎,并迁移 650 亿行数据库记录。

两次数据库迁移和一次分手 作者:Jack Ellis 2026年7月10日

我刚完成了职业生涯中最具挑战性的数据库迁移工作。我们正式发布 Fathom 4.0 版本,将超过 650 亿行数据迁移至全新的数据库架构,并彻底清除了所有技术债务。这个项目历时数月、耗费数百小时,且全程由我一名软件工程师独立完成。我会分享所有细节,所以请放松下来,泡杯咖啡,让我带你回顾这段旅程。

起源故事

早在 2019 年,我们就收到越来越多客户关于新兴 Cookie 法规的咨询。尽管 Fathom 简单、尊重隐私且仅收集极少数据,但我们当时仍在使用 Cookie。这些客户对此表示担忧,希望我们停止使用。

问题在于,我们需要通过 Cookie 来识别访客对站点是否独特、对页面是否独特。我们存储的数据结构如下:

{
    site_id: 'ABCDE',
    hostname: 'https://apple.com',
    pathname: '/',
    is_unique: true, // 首次访问站点?
    page_unique: true // 首次访问该页面?
}

因此,我们无法简单“抛弃 Cookie”,因为它是我们分析数据摄入基础设施的核心组成部分。

我们陷入了困境。我们知道法规正在变化,但整个数据模型都依赖 Cookie 来判断页面浏览是否属于独立访客。自远古时代起,分析软件就使用 Cookie 来统计独立访客。没有 Cookie,如何追踪独立访客?你不能使用访问日志,那是数百万个网站的原始数据,且包含个人信息(IP 地址)。你也不能仅对用户进行指纹识别,因为这能使其在潜在长达数年的时间里被唯一识别,且指纹是可再生的个人数据。我们无路可走。

我们不需要这些数据

直接使用原始访问日志是行不通的。网站主人当然可以在自己的网站上启用原始访问日志,但我们是家分析公司,要追踪数百万个网站、数十亿独立访客,绝不会想跨站点追踪个人、存储用户的浏览习惯。

想象一家广告公司吧,就叫它 Google 吧。它在保护用户隐私方面有一段耐人寻味的历史。自己保留原始访问日志,和在网站上装 Google Analytics,区别是这样的:

原始访问日志里你看到的内容,与 Google 看到的全网内容对比

后者等于给广告公司输送数据,让它们得以监控个人在全网的行踪。

这就是我们的起点。我们没法靠给用户打指纹或存 IP 来统计独立访客,否则就等于掌握了用户在整个互联网上的行为数据。但后来我们意识到,指纹也许真的可行。

隐私优先分析的诞生

传统的指纹肯定不行,因为它们可以复现,能够识别具体的网站访客。如果 [ip]:[deviceTraits] 的内容总是生成同样的结果,那我们就是在存储个人数据——规范里明令禁止。

那把明文哈希一下呢?应该就没事了吧?毕竟我们存的不是个人数据,而是哈希后的个人数据。不行,律师说,这过不了关,因为只要拿到 IP 和设备特征,就能轻易还原。凡是能轻易关联到具体用户的,都算在处理个人数据。

我们研究了隐私法规,发现律师说得对。这让我们想起当年业界的教训:对用户原始密码做一次简单的 MD5 哈希并不安全,因为坏人可以用彩虹表把 MD5 哈希反查回原始密码。那还是密码管理器没普及的年代,一旦密码被破解,你所有互联网账号往往跟着遭殃。业界的解决方案是加盐:md5('$jlJhiuweh2sl40' + $rawUserPassword)。软件工程师更进一步,给每个用户用不同的盐。就在这时我们豁然开朗——盐正是解决 Fathom cookie 难题的钥匙!

并非任意盐值。而是采用新鲜、草饲、有机的盐分策略,每天刷新,与站点 ID、主机名以及用户指纹一并加载。该盐值是随机字符串的 SHA-256 哈希值。

最终的用户标识符为:

hash('sha256', $siteId . $ip . $userAgent . $dailySaltSha256)

盐值会在不到 24 小时内失效,从而使得重构哈希变得不可能。其实,我第一篇博客文章已对此做了详细说明。

随后我们进行了风险分析。最坏的情况是什么?你无法暴力破解 256 位哈希。我在当时的博客中写道:

“暴力破解一个 256 位哈希的成本将是全球 GDP 的 10^44 倍。2019 年全球 GDP 为 88.08 万亿美元(88,080,000,000,000 美元),因此我们离暴力破解 256 位哈希还差了好几个零。”

即便假设存在恶意行为者针对特定个人,他们也需要获取该人的 IP 地址和 User Agent;接着还得入侵我们的数据库以获取所有唯一的 SHA-256 标识符;然后利用该 IP、User Agent、当天的盐值以及每一个 Fathom 跟踪 ID 的站点 ID 来生成哈希值;待这一切全部完成后,他们才能查看该用户在一天内访问过的站点。而到那时,若真发生这种事,我们面临的将是更严重的麻烦。

若没有每日盐值,攻击者便能重现单个用户长达数年的活动记录,那将是灾难性的。正是每日盐值让这一方案臻于完美。

这套由我们这家小公司在 2019 年为分析系统开发的技术方案,如今已无处不在。数十亿美元规模的企业阅读了我们的方法论(以及我的博客文章!),并采用了完全相同的系统。这很棒,因为一种简单的分析机制创新,为所有人带来了更好的互联网体验。

复利式的技术债

我们愉快地沿用了这套方案六年。数据模型基本保持不变,期间做了少量调整,包括 2021 年从 MySQL 迁移至 SingleStore,以解决反复出现的数据库崩溃问题。自 2021 年起,我们实际上就在 pageviews 中存储了 user_signature 列,但,这里有个大大的转折,我们从未真正使用过它。

相反,我们是这样处理传入的页面浏览和事件的:

{
    site_id: 'ABCDE',
    hostname: 'https://apple.com',
    pathname: '/',
    is_unique: Cache::get($uniqueIdentifier),
    page_unique: Cache::get($uniqueIdentifierIncludingPathname)
}

这两个哈希值存储在缓存中,用于在请求之间传递。我此处做了简化,实际上我们还有诸如 $uniqueIdentifier + '_source' 之类的机制,用来将 UTM 参数和来源页面关联到事件。但核心技术债务在于,我们在数据摄入阶段依赖外部缓存来确定分析数据的状态。这意味着,我们整个应用层面上的“独立访问者”统计,完全依赖于摄入时确定的 is_uniquepage_unique

随着隐私优先的分析市场演进,用户开始要求提供入口页、出口页、会话级 UTM 数据等。但由于我们整个模型从摄入到仪表盘都依赖于对唯一列执行 SUM() 函数,且唯一性在摄入时已确定,这些新功能很难引入。我们曾考虑添加 entry_pageexit_page 列。理论上入口页很容易,可以在页面浏览间缓存。但出口行不通,因为每当用户浏览新页面(其出口页随之改变),我们就得更新该用户此前所有的页面浏览记录。我绝不愿在摄入层进行大规模的分析更新,因为 我已经在那种做法上摔过跟头

我们在这一问题上拉锯许久,三人团队既要维持业务运转、开发新功能,又要摸索如何重构数据架构以满足软件的迫切需求。说实话,我们好几年都没能实现这些功能。我们陷得太深,找不到清晰的出路。每次即将取得数据层面的突破,总会冒出巨大的阻碍,导致数月工作付之东流。别误会:我们依然发布了新功能,业务增长了,拥有成千上万的满意客户,但数据层面始终步履维艰。说我们对数据工作已筋疲力尽,这还是客气了。

问题太多,为何我在此复盘?

之所以陷入僵局,是因为我曾试图将项目拆解为小的、可执行的步骤,却不愿承认我们面对的是一个庞大且错综复杂的技术债务泥潭。由于这些简单、可管理的步骤始终无法浮现,我陷入了分析瘫痪。

我们的数据问题清单越积越多,最后彻底纠缠在一起,感觉无从下手:

  1. 没法对数据跑 COUNT(DISTINCT()),因为 V1 和 Google Analytics 的数据(都是预先聚合的,标记为 type=site_statstype=page_stats)和 Fathom V2 数据混在同一张表里。放同一张表对某些查询确实有速度优势,但对其他一切都是噩梦。而且 count distinct 在大数据量下根本无法扩展,因为它要对 SHA-256 做计算并把结果留在内存里。即使用了内存占用小得多的 HyperLogLog,依然很慢。
  2. 因为 append-only 的时长/离开行,Fathom V2 数据存在重复行。对 pageviews 表直接 COUNT(*) 行不通,得排除离开行;但又离不开离开行,因为它带 exits=-1,用来抵消首个 pageview 上默认的 exits=1。要在统计退出数的同时算页面/站点停留时长,逻辑变得一团糟。再想对非 v1/ga、非 duration 的 append 等做各种 count 的 union,就更慢了。
  3. 入口/出口页面无法大规模跑。我们用窗口函数搭了一套查询结构,数据是能出,但即便在我们性能很强的数据库集群上也撑不住。
  4. 没有访客会话级别的数据概念。要按 referrer 找独立用户,就得扫描每一个 pageview。我们想要一张 sessions 表和一张 events 表。
  5. 需要更新历史 pageview 的退出页面。用户每访问一个新页面,之前那条 pageview 行上的退出页面就得更新。这种模式在 OLAP 负载下无法扩展,哪怕是在 SingleStore 这样出色的 HTAP 数据库上也不行。
  6. 无法乱序处理 pageview(比如走一个无序队列),因为我们的缓存依赖独立访客数据。处理旧的 pageview 时,会以各种方式影响缓存。这个问题解释起来能写上千字,总之缓存从一开始就是建立在 pageview 和事件按顺序处理的假设之上的。所以我们在后台异步处理数据的能力非常受限。
  7. 事件的表里有一个 goal_id 字段,通过事务关联到同一个数据库。为了拆分 OLTP 和 OLAP 工作负载,每当有新媒体事件到达 Fathom 时,我们都要获取锁,以便在目标表中为相应事件创建一条记录。
  8. 推荐来源没有在整个会话的所有页面浏览量中保持一致。如果访客来自 Google 并访问了另外五个页面,只有首个落地页会显示 Google,其余四个页面会被算作直接访问,产生一个独立的访客和页面浏览量。
  9. 跳出率是按 30 分钟的会话来计算的,而不是按天。虽然有些产品可以接受这种口径,但在我看来,这种计算方式总显得不太合理。
  10. 停留时长是指两个已处理页面浏览之间流逝的时间。我们从未设计过专门的离开信号,因此每个会话的最后一个页面停留时长总是 0 秒,我们也无法获取完整的停留时长。
  11. 当按路径过滤时,顶部汇总数据与页面统计盒中的人员指标之间存在脱节,这导致收到大量客服咨询。

我们遇到的问题实在太多了,但这就是在新兴市场中作为先行者的现实。这些是其中最主要的问题。我们投入了大量资金和时间,试图构建 analytics_sessionsanalytics_events 数据库表模型,其中 analytics_sessions 为每个网站访客保存一行记录。通过 Upserts 更新入口和出口页面,然后利用时间戳来条件性地更新这些列(例如,如果新时间戳早于会话的入口时间戳,则该页面浏览将优先于会话表中保存的数据)。这种设计让我们能够以乱序处理页面浏览。最终,在与几位朋友交流后,我意识到如果不做出各种取舍,这种方案根本无法扩展,于是就被放弃了(美式英语:trashed,即被彻底否决)。

一切都被复杂的困扰所缠绕。没有人能向我解释我们背负的技术债务有多复杂,但如果你是软件工程师,你会理解这种感受。另一个问题在于,尽管存在上述种种问题,我们的客户对现有产品却感到满意。因此,比起打破现状,维持原状要容易得多。但客户依赖你为他们的付费软件做出正确的决策。我深刻意识到,这种变革不可能通过小修小补来完成,它要求我们对整个分析模块进行重构。

扭转习惯

临近 2025 年末,我暂停了“小步快跑”的原则。在这个项目上,想靠微调已不可行,且我们此前频频碰壁。我暂缓了产品路线图,全力聚焦数据处理。我聘请了一位出色的数据库专家,评估各种方案,一套计划逐渐成形。实施过程注定艰难,我也无从预估工期。

2025 年 11 月 24 日,转机出现。新款 AI 模型 Opus 4.5 发布。我曾用 AI 辅助编程,但体验不佳。尽管众人推崇,在我看来它只是个碍事的自动补全工具。Opus 4.5 改变了一切。我负责架构和方向,却无需编写代码。我仍会审查每一行,但切换不同实现的周期从几周缩短到几分钟。在多个小项目中试用 Cursor 后,我已彻底沉迷,每天投入 8 小时以上。此刻,我已准备好迎接自 2020 年大规模 DDoS 攻击以来,公司面临的最大挑战。

分道扬镳

过去几年,我意识到我们并未真正涉及 HTAP 混合负载。我们的 OLTP 和 OLAP 业务大多彼此独立。2025 年末,我们开始关注 ClickHousePlanetScale。ClickHouse 是广受使用的开源 OLAP 数据库,PlanetScale 则是 OLTP 领域的行业领袖。令我惊喜的是,在与双方沟通时,均未要求签署年度合约。这始料未及,却从最初便建立起了信任。

ClickHouse 的 Ben Paul 协助我优化新的数据库架构,并主动帮助我们降低成本。其解决方案工程理念至今仍令我震撼。他曾说:“你可以缩减规模,我不认为你需要这么多资源。”企业展现这种坦诚,能建立长期信任,证明他们珍视合作关系。Ben 和 Luis Neves 甚至与我通话,深入探讨 ClickHouse 的工作原理。

在 PlanetScale 这边,Chris Munns 帮我深入了解了 PlanetScale,并支持我迁移了我们的 OLTP 工作负载(相比他们平时处理的规模, ours 实在很小)。Ben Dicken 也很热心,给我讲解了他们的数据库副本(replica)是如何工作的。

2025 年 12 月 2 日,我正式通知 SingleStore 我们不再续约。邮件发出那一刻,倒计时就开始了。我什么都还没动手,但我很清楚,自己需要这种时间压力来逼自己加快速度。合同到 2 月 28 日截止,这是一个死线:必须在此之前把分析引擎彻底重建,并完成所有数据的迁移。

事后回想,这个决定相当疯狂。我只有不到三个月的时间,要独自重写整个分析系统、仪表盘和数据采集流程,然后完成全部迁移,同时还得处理公司其他各种事务,并做一个尽责的爸爸。但事已至此,没有回头路了。

拆解 JOIN

刚开始动手,我就发现我们陷进了泥潭。OLTP 工作负载(用户、目标、国家、站点)根本无法与分析工作负载分离,因为我们在 SingleStore 里执行了大量 JOIN。所以这是第一个要攻克的地方。

我们把每一类 JOIN 都拆解了:

  1. Goals:以前通过分析事件表和 goals 表之间的 JOIN 获取(把 goal_id 映射到 goals.name),现在改为独立的查询,配合动态数组映射。
  2. Countries:转换为缓存的静态映射表,不再依赖数据库表。
  3. 所有关联关系,比如 Site::pageviews()Event::goal(),全部重写。

这一阶段的改动仍然跑在 SingleStore 上,唯一的区别是数据改为通过独立查询获取。就这样,我们把 OLTP 工作负载和 OLAP 工作负载解耦了。

这缓存可真烧钱

SingleStore 的一大优势是,在集群扩容后我们获得了大量内存。针对 SingleStore rowstore 的操作速度极快,且我们读取的键值很大,但删除操作却经常消耗大量 CPU。我拉取了一些数据,发现缓存工作负载的一部分主导了 CPU 使用率。删除操作来自 Laravel,因为其缓存数据库驱动就是这样工作的:一旦获取到已过期的键,它会立即将其删除。有趣的是,这种做法完全没问题,因为在大规模场景下,谁还会用数据库作为缓存层呢?没人会。大家肯定都在用 Redis。

但我们当时并非如此。得益于充足的内存,用 SingleStore 做缓存是合理的。我当时坚信这是正确的选择。但现实情况是,我们竟然在用 HTAP 数据库处理缓存负载。每处理一个页面浏览请求,就需要与数据库进行 3 到 8 次交互。我们在使用一个高度专业化的数据库来处理键值缓存工作。

SingleStore 查询性能显示缓存删除和选择主导 CPU

开关切换

在着手重建分析功能时,我们取得了一项突破,虽然事后看来这似乎显而易见。我们意识到可以在仪表板上添加一个开关按钮,仅管理员可见,用于在不同数据源之间切换。这意味着我完全不需要修改 SingleStore 分析模块。我们可以复制代码,将其转换为 ClickHouse 友好的查询,而用户根本不会察觉,因为他们看不到这个开关。

新的 ClickHouse 分析模块完全由 Cursor 编写,长达数千行。我阅读了每一行代码,感到非常满意。所有代码都复制自我们的原始类,因为我假设最终总会移除 SingleStore 版本。

过去,我们曾尝试通过功能标志让单个类根据用户表现不同,但正如你可以想象,那成了一场大混乱。这次,我在更高层级——控制器层面——构建了切换逻辑。就这样,当我们选择时,当前访客和所有仪表板数据框都会从 ClickHouse 加载数据。

规划撤离


随着解耦完成,我们得以将 OLTP 和 OLAP 负载分开。由于模式已复制到 ClickHouse,我们可以开始迁移数据以进行对比测试(通过切换开关实现!)。 但此时我们面临新挑战:如何将数千亿行数据库数据从 SingleStore 迁移到 ClickHouse?如果做不到这一点,一切将功亏一篑。 这是我此前参与过的所有项目中数据量最大的一次迁移。由于是在不同的数据库软件之间进行,我知道这会是一场硬仗。 我反复修改了八版迁移脚本。起初,我试图耍点小聪明,为每个日历日创建单独的 Parquet 文件。第一个问题是,我没有使用排序键(site_id, timestamp)来取数据,导致我的 where timestamp between 查询速度极慢。 第二版迁移脚本仍试图按时间分块,但将多天合并为一个块。结果收效甚微,与第一版相比并无显著改进。 到了第三版,我们改为按站点和日期提取数据。这意味着可以利用排序键。虽然这让读取查询速度快了 75 倍,非常棒,但文件大小的波动却高达 33 倍,这不是好现象。 在第四至第八版迭代中,我不满意 SingleStore -> S3 -> ClickPipes -> ClickHouse 的方案。我花了很多时间尝试不同方法,最终发现 ClickHouse 可以通过 MySQL 协议直接从我们的 SingleStore 数据库读取数据(因为 SingleStore 兼容 MySQL 线路协议)。这一发现成为了改变一切的突破口。 到第八版时,我已经构建了一个具备上下文感知能力的复杂迁移系统。我还对迁移过程进行了细致入微的性能剖析,因为我知道随着错误的发现,可能需要多次运行迁移脚本,因此吞吐量至关重要。结论是,我们需要“站点包”块来迁移特定范围的站点 ID,以及“巨站”块来单独迁移大型站点。

实际上这套流程我先在本地机器上跑了一遍,以便获得最完整的洞察。我们基于生产环境的分析数据,在本地数据库里创建了这些 mega site 和 site bundle 分块。从下图可以看到,我们配置了 site bundle,这样 site_id 从 0 到 1946 的数据可以在一个任务里完成迁移,因为这部分只有 8 亿行。截图里没显示出来的是,我们还有另一种 chunk_type 叫 "mega site",它的 start_site_id 和 end_site_id 是同一个站点 ID,由于要迁移的数据有几十亿行,我们改用时间戳来分块。

迁移分块表,显示带起止站点 ID 和行数的 site_bundle 行

我们还内置了校验机制:先确定初始行数,然后跟踪 rows_exported,确保 ClickHouse 目标端的数据量与预期一致。这个方案花费了大量精力,但效果非常棒。

最终,从 SingleStore 迁移到 ClickHouse 的吞吐量达到了每秒 70 万到 100 万行。我在本地用 Laravel jobs 运行迁移调度,但实际的数据传输发生在 ClickHouse 和 SingleStore 之间——我向 ClickHouse 发指令让它从 SingleStore 读取数据,这一切都在云端完成。

终端输出显示 V8 导出 worker 以每秒 70 万至 83 万行的速度迁移 pageview 分块

听到我没在生产数据库上直接跑迁移,你肯定不会惊讶。我先把数据库做了备份,恢复到一个超大规模的 SingleStore 集群(真的非常大)上,然后就可以放心地用大量查询去压测它。此外,我们只迁移到某个固定时间戳为止的数据,而历史数据是不会变的——这正是我们能够采用这种迁移方式的前提。ClickHouse 也做了扩容来应对负载。

SingleStore 仪表盘显示大量导出查询正在临时生产克隆集群上执行

就这样,我们搭好了整个迁移的基座,我也确信一切都会按计划进行。

输过才知道怎么赢

我们最初的 ClickHouse 设计包含一张专门的 sessions 表、三个物化视图以及 AggregatingMergeTree 引擎。一切看起来都很完美。但当我们加载一个中型 Fathom 站点(约 6500 万页面浏览量)的仪表板时,加载耗时竟达 6 到 8 秒,与 SingleStore 的表现相当。诚然,我们使用的是更低配版的 ClickHouse 方案,但我本以为它的性能会快得多。

我花了些时间与 Ben(ClickHouse 团队)交流并自行调试,我们意识到,这种整体架构无法达到我们期望的速度(和成本)标准。因为在某些查询中,我们仍需将 sessions 与 events 进行关联。虽然由于 sessions 表中每个唯一访客只有一行数据,对其执行 COUNT(*) 很快,但其他查询就不一样了。

正是在这一刻,我们意识到必须采用一种多年来一直在回避的优化技术。

一次昂贵,永久便宜

虽然单表方案在中小站点上很快,但在处理大型站点时,一旦按转介来源筛选或进行站点访客层面的会话级操作,系统就会不堪重负。因此,是时候创建 rollup 表(汇总预聚合表)了。

Rollup 表是指按不同维度预先聚合数据,从而避免在查询时对数百万(甚至数十亿)行数据动态执行计数或去重计数。使用 rollup 表,现实地讲,十亿行数据可以压缩到几百万到几千万行。

于是我们构建了四张日度 rollup 表并调整了模型。我们将数据汇总到 analytics_daily、analytics_daily_breakdown 等表中,并在数据库里存储一个 rolled_up_to 变量,以便知道已有汇总数据可用。在此之后产生的新数据,则通过查询原始 events 表来获取。没错,你猜得对,对 events 的查询会变慢,但它仅涉及少量数据。同时,后台会持续运行 rollup 进程,不断推进 rolled_up_to 变量的值。

我们数据模型的最终归宿,是聚合数据与原始事件之间的 Union 查询。由于数据以 UTC 存储,而前端使用动态时区,我们实际上需要在中间时段使用 UTC 聚合数据,但在时间范围的边缘部分则需调用原始事件表,因为聚合数据是基于 UTC 的。这听起来可能有点绕,但这种设计让我们得以在单个站点上支持多时区动态切换,同时仍然利用中间时段的聚合数据来提升性能。时间范围起点的 24 小时以及终点的 24 小时,正是时区带来的主要难点。

当这套逻辑敲定时,感觉真是太爽了。我们甚至能将导入的分析数据也通过 UNION 合并进来,这些数据现在单独存储在 analytics_imported 表中。很长一段时间里,我都试图让所有数据契合单一模型。单表查询确实完美,因为只需执行 SUM(),而 UNION 听起来像是一场灾难,对吧?结果证明,我错了。Union 查询的性能表现出色。最终,我们意识到针对不同查询形状需要多个事实源。直到 2026 年初,我们还有两张表,pageviews 和 events,并列存放着 Fathom V1、V2 数据以及从 Google Analytics 导入的所有数据。运行超出简单 SUM 的分析查询成本高昂。多表聚合模型最终让大型站点的数据查询变得瞬间响应。

顺便一提,那个耗时 6 到 8 秒的查询现在缩短到了亚秒级,每个仪表盘的速度都有了质的飞跃。

零失误审计

理想模型就位后,我们调整了迁移代码,换了一种方式写入表数据(博客里轻描淡写的一句话,背后却是繁重的工作),然后准备开始测试。

我们执行了迁移,测试了数据,一切完美。虽然有些惊讶,但回顾整个过程,我们花了数百小时精心规划。我们极度重视客户分析数据的安全保护,考虑到了方方面面。

我们引入双写机制,观察 ClickHouse 在数据摄入时的表现。起初使用同步写入,以便捕获数据写入过程中的任何错误;确认稳定后,我们转向异步插入(即对写入 ClickHouse 的数据进行批量处理)。

我们对现有状态满意,迁移的时机已至。

战斗开始

迁移当天早上,我们把临时的大型 SingleStore 实例重新启动。此时我们面临按小时计的倒计时,因为这个实例必须在下午 6 点前关掉。合同截止日期是 2 月 28 日,而当天已经是 26 日。dual-write 早已就位,所以指定截止点(2026-02-26 15:00:00)之后的所有数据都会通过双写进入 ClickHouse。我们现在的任务,是把剩余的 OLTP 负载迁移到 PlanetScale,并把截止点之前的数十亿行数据回填过去。整个迁移过程中我一直在前线做记录,现在如实分享出来,让你看到:哪怕做过很多次,迁移依然是一团乱麻。 我做的第一步,是确认 SingleStore 和 ClickHouse 之间的双写数据完全一致。只要有偏差,整个计划就泡汤了。验证无误,我们继续推进。 由于当初在 ClickHouse 里选的分区键,我们遇到了一些麻烦。长话短说:events 表的结构是为数据写入设计的,不是为迁移设计的。所以当我们把数十亿行数据灌进 ClickHouse 时,它累积了大量待合并的 "parts",由此引发了不少大问题。不过都在可控范围内。 我们不得不引入一张采用不同分区键的 staging 表(events_staging),随后又意识到这张表需要按时间戳排序,这样我们才能逐月把数据从 staging 处理进正式表(等到准备把数据从 staging 搬进生产环境时)。我也意识到不能再干等着迁移自己跑完,得让自己并发起来。今天和明天必须搞定。我们能行。 此时,我清单上剩下的任务是:
  1. 重新备份 SingleStore 数据库,因为 parts 问题导致了数据丢失,必须把截止时间往后推。没问题,我们设双写正是为了应对这种情况。
  2. 把 SingleStore 数据库恢复到我们的大型临时集群上(简单但很慢)。
  3. 扩大 ClickHouse 集群规模以承载导入负载。
  4. 运行迁移,导入我们的 Google Analytics 导入数据(无需 staging)。
  5. 将所有已迁移数据与生产环境的 SingleStore 数据库进行校验。
  6. 完成 lazy goal creation 的实现和查询改动,把它从热路径(ingest)中挪出去。
  7. 移除事件上的属性创建逻辑(这是旧代码,尚未上线)。
  8. 在生产环境执行 ALTER TABLE events DROP COLUMN goal_id,因为该列已不再需要。
  9. 构建离开打点端点并集成到 ClickHouse 脚本中,从而不再依赖服务器来追踪页面浏览之间的时间间隔。
    1. 创建一张测试表,用于验证接收到的数据是否正常(已确认:数据状态良好,存在少量 v1 版本的细微差异,但在可接受范围内)。
  10. 对 events 表逐天运行汇总命令,生成我们精美的汇总表。
  11. 获取 Redis 用于仪表盘缓存和摄入缓存(希望当前访客能够共享资源)。
  12. 为所有数据库访问(如站点配置查找、目标查找)添加 30 分钟的缓存层。
  13. 删除 SingleStore 中的任何旧表。
  14. 为每个 OLTP 表制定迁移计划。
  15. 将仪表盘的缓存驱动更换为 Redis。

2 月 26 日就这样过去了,距离合同到期还有 2 天。

箭在弦上

2 月 27 日,我精力充沛地醒来,准备大干一场。我执行了列表上的所有操作。我们迁移了约 650 亿行数据,随后运行了汇总任务(它们运行了一整天)。一切都显得轻松得难以置信。所有对比数据看起来都完美无缺,仪表盘匹配,摄入数据也一致,我甚至感到震惊。于是,我做了任何软件工程师在这种情境下都会做的事:为了以防万一,我又把代码看了两遍。但事情还没完,我们整个 OLTP 工作负载还在后面。包括用户表、站点表以及其他所有内容。

心流与专注

到了当天这个时间点,我已进入全神贯注的状态。我知道我行动很快,也知道我们肯定会遇到一些 Bug,但我决定某些 Bug 是可以接受的,而另一些则不行。我们是一家分析公司,我最关心的就是分析数据。如果电子邮件报告、联盟支付或数据导出出现问题,那确实不好,但我们可以轻松从中恢复。

我配置了 PlanetScale 与我们的应用连接。此前我已在他们的数据库上测试过主要查询,由于它本质上是一个 MySQL 数据库,所以我心里有底。我通过风险分级来实施迁移,将表分为低风险和高风险。我们有一些我认为是“静态表”的数据。好吧,说它们完全是静态的不准确,但它们的更新频率确实很低。

  1. clickhouse_rollup_log
  2. countries
  3. data_exports_v2(仅迁移表结构,功能仍在开发中)
  4. failed_jobs(仅迁移表结构)
  5. imports(静态数据,仪表盘中的导入功能暂时锁定)
  6. ingest_api_tokens(新的 proxy 功能,预计未来几个月内上线)
  7. jobs
  8. migrations
  9. milestones(仅迁移表结构)
  10. notifications
  11. overage_exclusion_history
  12. password_resets(仅迁移表结构)
  13. personal_access_tokens(仅迁移表结构)
  14. sessions(仅迁移表结构,可选择终止会话或迁移至 Redis)
  15. affiliate_payments
  16. support_guidelines

对于部分表,我们连同数据一并迁移了。对于迁移过程中可能发生变更的表,我们只迁移了表结构。

至此,我回到了 OLAP 相关的迁移工作。同事 Ash 已经完成了测试,我们准备将全部流量切换到 ClickHouse。我们迁移了以下部分:

  1. 邮件报表
  2. 数据导出
  3. API 聚合端点
  4. 过滤器自动补全
  5. 移除 PageviewRepository,将数据导入改为写入 analytics_imported。EventRepository 也做了相同处理
  6. Site.php 中的 CalculateAnalyticsStartsAt 逻辑

在此特别感谢 Ash,在我忙于协调迁移工作的同时,他协助测试了上述全部内容。

完成这些低风险的 OLTP 表迁移后,我们继续将一些表结构迁移到 PlanetScale:

  1. site_integrations
  2. subscriptions
  3. subscription_items
  4. users
  5. user_referral_codes
  6. goals
  7. email_report_subscriber_sites
  8. email_report_subscribers

随后,我将另外几个表结构迁移至 ClickHouse:

  1. personal_access_token_performance
  2. referral_code_performance

并修改了应用代码,使其同时向 ClickHouse 中的这些表写入数据。

停机窗口

我极度抗拒服务中断。我总希望零停机。但当时已接近 2 月 27 日的末尾,我意识到在迁移那些动态变化的表时,维持应用在线并不现实。那时已是一天中流量较低的时间段。于是我将应用下线了不到 30 分钟。期间分析数据仍能正常写入,但仪表盘暂时无法访问。由于此时大多数客户已入睡,我们没有收到任何客户投诉。

剩余的 OLTP 表数据迁移包括:

  1. milestones(数据)
  2. personal_access_tokens(数据)
  3. site_integrations (data)
  4. subscriptions (data)
  5. subscription_items (data)
  6. users (data)
  7. user_referral_codes (data)
  8. data_exports_v2 (data)
  9. affiliate_payments (data)
  10. email_report_subscriber_sites
  11. email_report_subscribers
  12. sites (data)
  13. goals (data)

接着我对所有迁移过的表跑了一遍计数,把遗漏的(比如 goal 创建相关的)补拷过来,一切看起来都很顺利。

我把默认数据库连接切换到 PlanetScale,然后又把 ingest 的默认连接也切到 PlanetScale 用于读取。

最后我移除了 ingest 端点中所有与 SingleStore 的交互。至此,SingleStore 已从我们的应用中彻底清除。

再见,SingleStore

2026 年 2 月 28 日凌晨 2 点,我暂停了我们的 SingleStore 集群。一个时代结束了。

生产环境 workspace 的 SingleStore 暂停确认对话框

我保留了集群,以防需要恢复什么数据,但后来再也没有打开过它。

我想借这段文字表达对 SingleStore 过去五年支持的感激。接下来我要谈成本节省了,但在那之前,必须先郑重感谢 SingleStore 为我们做的一切。我不再把 SingleStore 看作面向小企业的数据库,而是把它看作一种非常成熟的数据库方案,适合需要真正兼顾 OLAP 和 OLTP 负载的用户。

第二天

3 月 1 日,客户纷纷称赞仪表盘速度飞快。大获成功。我醒来时有些疲惫,参加了一个家庭早餐会,之后一整天都在收拾行李,因为第二天要飞去英国。

成本节省

技术深挖文章不讲讲省了多少钱怎么行。先做几点说明:

  1. 这不是严格的 1:1 对比。我做出的一些基础设施决策降低了整体工作负载,所以不要把图中的数据当作等价对比来看,这只是我们前后的实际支出。
  2. 我对比了 SingleStore 与我们新数据库方案的按需定价。SingleStore 官网提到,签一年期合同可享受折扣。我们确实签了年付合同,但把这个价格公开不太合适。

具体成本明细如下:

基础设施成本前后对比:SingleStore 每月 23,126 美元 vs ClickHouse、PlanetScale 和 ElastiCache 每月 2,230 美元

有个有趣的事:我们最初打算把所有服务都重构到 SingleStore 上。他们一直待我很好,我也想着可以从 S-8 降到 S-2。S-2 配 16 vCPU 和 128GB 内存,算力跟 ClickHouse 相当,内存更大,还带个大容量的本地磁盘缓存。但那样算下来,SingleStore 按需价格要 5,781 美元/月,要是需要多可用区(multi-AZ)则升至 7,515 美元/月。这比 ClickHouse 的按需价格贵了 4 倍。

SingleStore 是一家 HTAP 数据库服务商,专精于 OLTP/OLAP 混合负载,而且做得非常出色。我之前在 这篇文章里 已经明确说过,他们的技术有多强。如果确实需要真正的 HTAP,我真心认为 SingleStore 是目前最好的选择。

接下来怎么办?

我们不会放缓脚步。我们在数据分析领域已经深耕八年,对运行分析负载的经验积累得越来越多。写软件不难,难的是挺过艰难时刻,而长期留在牌桌上才真正考验这种韧性。

尘埃落定后,我可以确认:更好的还在后头。

Jack Ellis,创始人

认识 Jack Ellis 的专长,他身兼技术写作、教育和软件工程多职,在软件开发方面经验丰富。

每月获取更多类似文章(大概吧)

订阅成为第一时间知道此类新文章发布的人。

订阅
原始来源: usefathom

评论 (0)