两次数据库迁移与一次“分手”:重构 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,区别是这样的:
后者等于给广告公司输送数据,让它们得以监控个人在全网的行踪。
这就是我们的起点。我们没法靠给用户打指纹或存 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_unique 和 page_unique。
随着隐私优先的分析市场演进,用户开始要求提供入口页、出口页、会话级 UTM 数据等。但由于我们整个模型从摄入到仪表盘都依赖于对唯一列执行 SUM() 函数,且唯一性在摄入时已确定,这些新功能很难引入。我们曾考虑添加 entry_page 和 exit_page 列。理论上入口页很容易,可以在页面浏览间缓存。但出口行不通,因为每当用户浏览新页面(其出口页随之改变),我们就得更新该用户此前所有的页面浏览记录。我绝不愿在摄入层进行大规模的分析更新,因为 我已经在那种做法上摔过跟头。
我们在这一问题上拉锯许久,三人团队既要维持业务运转、开发新功能,又要摸索如何重构数据架构以满足软件的迫切需求。说实话,我们好几年都没能实现这些功能。我们陷得太深,找不到清晰的出路。每次即将取得数据层面的突破,总会冒出巨大的阻碍,导致数月工作付之东流。别误会:我们依然发布了新功能,业务增长了,拥有成千上万的满意客户,但数据层面始终步履维艰。说我们对数据工作已筋疲力尽,这还是客气了。
问题太多,为何我在此复盘?
之所以陷入僵局,是因为我曾试图将项目拆解为小的、可执行的步骤,却不愿承认我们面对的是一个庞大且错综复杂的技术债务泥潭。由于这些简单、可管理的步骤始终无法浮现,我陷入了分析瘫痪。
我们的数据问题清单越积越多,最后彻底纠缠在一起,感觉无从下手:
- 没法对数据跑
COUNT(DISTINCT()),因为 V1 和 Google Analytics 的数据(都是预先聚合的,标记为type=site_stats或type=page_stats)和 Fathom V2 数据混在同一张表里。放同一张表对某些查询确实有速度优势,但对其他一切都是噩梦。而且 count distinct 在大数据量下根本无法扩展,因为它要对 SHA-256 做计算并把结果留在内存里。即使用了内存占用小得多的 HyperLogLog,依然很慢。 - 因为 append-only 的时长/离开行,Fathom V2 数据存在重复行。对 pageviews 表直接
COUNT(*)行不通,得排除离开行;但又离不开离开行,因为它带exits=-1,用来抵消首个 pageview 上默认的exits=1。要在统计退出数的同时算页面/站点停留时长,逻辑变得一团糟。再想对非 v1/ga、非 duration 的 append 等做各种 count 的 union,就更慢了。 - 入口/出口页面无法大规模跑。我们用窗口函数搭了一套查询结构,数据是能出,但即便在我们性能很强的数据库集群上也撑不住。
- 没有访客会话级别的数据概念。要按 referrer 找独立用户,就得扫描每一个 pageview。我们想要一张 sessions 表和一张 events 表。
- 需要更新历史 pageview 的退出页面。用户每访问一个新页面,之前那条 pageview 行上的退出页面就得更新。这种模式在 OLAP 负载下无法扩展,哪怕是在 SingleStore 这样出色的 HTAP 数据库上也不行。
- 无法乱序处理 pageview(比如走一个无序队列),因为我们的缓存依赖独立访客数据。处理旧的 pageview 时,会以各种方式影响缓存。这个问题解释起来能写上千字,总之缓存从一开始就是建立在 pageview 和事件按顺序处理的假设之上的。所以我们在后台异步处理数据的能力非常受限。
- 事件的表里有一个
goal_id字段,通过事务关联到同一个数据库。为了拆分 OLTP 和 OLAP 工作负载,每当有新媒体事件到达 Fathom 时,我们都要获取锁,以便在目标表中为相应事件创建一条记录。 - 推荐来源没有在整个会话的所有页面浏览量中保持一致。如果访客来自 Google 并访问了另外五个页面,只有首个落地页会显示 Google,其余四个页面会被算作直接访问,产生一个独立的访客和页面浏览量。
- 跳出率是按 30 分钟的会话来计算的,而不是按天。虽然有些产品可以接受这种口径,但在我看来,这种计算方式总显得不太合理。
- 停留时长是指两个已处理页面浏览之间流逝的时间。我们从未设计过专门的离开信号,因此每个会话的最后一个页面停留时长总是 0 秒,我们也无法获取完整的停留时长。
- 当按路径过滤时,顶部汇总数据与页面统计盒中的人员指标之间存在脱节,这导致收到大量客服咨询。
我们遇到的问题实在太多了,但这就是在新兴市场中作为先行者的现实。这些是其中最主要的问题。我们投入了大量资金和时间,试图构建 analytics_sessions 和 analytics_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 年末,我们开始关注 ClickHouse 和 PlanetScale。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 都拆解了:
- Goals:以前通过分析事件表和 goals 表之间的 JOIN 获取(把
goal_id映射到goals.name),现在改为独立的查询,配合动态数组映射。 - Countries:转换为缓存的静态映射表,不再依赖数据库表。
- 所有关联关系,比如
Site::pageviews()和Event::goal(),全部重写。
这一阶段的改动仍然跑在 SingleStore 上,唯一的区别是数据改为通过独立查询获取。就这样,我们把 OLTP 工作负载和 OLAP 工作负载解耦了。
这缓存可真烧钱
SingleStore 的一大优势是,在集群扩容后我们获得了大量内存。针对 SingleStore rowstore 的操作速度极快,且我们读取的键值很大,但删除操作却经常消耗大量 CPU。我拉取了一些数据,发现缓存工作负载的一部分主导了 CPU 使用率。删除操作来自 Laravel,因为其缓存数据库驱动就是这样工作的:一旦获取到已过期的键,它会立即将其删除。有趣的是,这种做法完全没问题,因为在大规模场景下,谁还会用数据库作为缓存层呢?没人会。大家肯定都在用 Redis。
但我们当时并非如此。得益于充足的内存,用 SingleStore 做缓存是合理的。我当时坚信这是正确的选择。但现实情况是,我们竟然在用 HTAP 数据库处理缓存负载。每处理一个页面浏览请求,就需要与数据库进行 3 到 8 次交互。我们在使用一个高度专业化的数据库来处理键值缓存工作。
开关切换
在着手重建分析功能时,我们取得了一项突破,虽然事后看来这似乎显而易见。我们意识到可以在仪表板上添加一个开关按钮,仅管理员可见,用于在不同数据源之间切换。这意味着我完全不需要修改 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,由于要迁移的数据有几十亿行,我们改用时间戳来分块。
我们还内置了校验机制:先确定初始行数,然后跟踪 rows_exported,确保 ClickHouse 目标端的数据量与预期一致。这个方案花费了大量精力,但效果非常棒。
最终,从 SingleStore 迁移到 ClickHouse 的吞吐量达到了每秒 70 万到 100 万行。我在本地用 Laravel jobs 运行迁移调度,但实际的数据传输发生在 ClickHouse 和 SingleStore 之间——我向 ClickHouse 发指令让它从 SingleStore 读取数据,这一切都在云端完成。
听到我没在生产数据库上直接跑迁移,你肯定不会惊讶。我先把数据库做了备份,恢复到一个超大规模的 SingleStore 集群(真的非常大)上,然后就可以放心地用大量查询去压测它。此外,我们只迁移到某个固定时间戳为止的数据,而历史数据是不会变的——这正是我们能够采用这种迁移方式的前提。ClickHouse 也做了扩容来应对负载。
就这样,我们搭好了整个迁移的基座,我也确信一切都会按计划进行。
输过才知道怎么赢
我们最初的 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 搬进生产环境时)。我也意识到不能再干等着迁移自己跑完,得让自己并发起来。今天和明天必须搞定。我们能行。 此时,我清单上剩下的任务是:- 重新备份 SingleStore 数据库,因为 parts 问题导致了数据丢失,必须把截止时间往后推。没问题,我们设双写正是为了应对这种情况。
- 把 SingleStore 数据库恢复到我们的大型临时集群上(简单但很慢)。
- 扩大 ClickHouse 集群规模以承载导入负载。
- 运行迁移,导入我们的 Google Analytics 导入数据(无需 staging)。
- 将所有已迁移数据与生产环境的 SingleStore 数据库进行校验。
- 完成 lazy goal creation 的实现和查询改动,把它从热路径(ingest)中挪出去。
- 移除事件上的属性创建逻辑(这是旧代码,尚未上线)。
- 在生产环境执行 ALTER TABLE events DROP COLUMN goal_id,因为该列已不再需要。
- 构建离开打点端点并集成到 ClickHouse 脚本中,从而不再依赖服务器来追踪页面浏览之间的时间间隔。
- 创建一张测试表,用于验证接收到的数据是否正常(已确认:数据状态良好,存在少量 v1 版本的细微差异,但在可接受范围内)。
- 对 events 表逐天运行汇总命令,生成我们精美的汇总表。
- 获取 Redis 用于仪表盘缓存和摄入缓存(希望当前访客能够共享资源)。
- 为所有数据库访问(如站点配置查找、目标查找)添加 30 分钟的缓存层。
- 删除 SingleStore 中的任何旧表。
- 为每个 OLTP 表制定迁移计划。
- 将仪表盘的缓存驱动更换为 Redis。
2 月 26 日就这样过去了,距离合同到期还有 2 天。
箭在弦上
2 月 27 日,我精力充沛地醒来,准备大干一场。我执行了列表上的所有操作。我们迁移了约 650 亿行数据,随后运行了汇总任务(它们运行了一整天)。一切都显得轻松得难以置信。所有对比数据看起来都完美无缺,仪表盘匹配,摄入数据也一致,我甚至感到震惊。于是,我做了任何软件工程师在这种情境下都会做的事:为了以防万一,我又把代码看了两遍。但事情还没完,我们整个 OLTP 工作负载还在后面。包括用户表、站点表以及其他所有内容。
心流与专注
到了当天这个时间点,我已进入全神贯注的状态。我知道我行动很快,也知道我们肯定会遇到一些 Bug,但我决定某些 Bug 是可以接受的,而另一些则不行。我们是一家分析公司,我最关心的就是分析数据。如果电子邮件报告、联盟支付或数据导出出现问题,那确实不好,但我们可以轻松从中恢复。
我配置了 PlanetScale 与我们的应用连接。此前我已在他们的数据库上测试过主要查询,由于它本质上是一个 MySQL 数据库,所以我心里有底。我通过风险分级来实施迁移,将表分为低风险和高风险。我们有一些我认为是“静态表”的数据。好吧,说它们完全是静态的不准确,但它们的更新频率确实很低。
- clickhouse_rollup_log
- countries
- data_exports_v2(仅迁移表结构,功能仍在开发中)
- failed_jobs(仅迁移表结构)
- imports(静态数据,仪表盘中的导入功能暂时锁定)
- ingest_api_tokens(新的 proxy 功能,预计未来几个月内上线)
- jobs
- migrations
- milestones(仅迁移表结构)
- notifications
- overage_exclusion_history
- password_resets(仅迁移表结构)
- personal_access_tokens(仅迁移表结构)
- sessions(仅迁移表结构,可选择终止会话或迁移至 Redis)
- affiliate_payments
- support_guidelines
对于部分表,我们连同数据一并迁移了。对于迁移过程中可能发生变更的表,我们只迁移了表结构。
至此,我回到了 OLAP 相关的迁移工作。同事 Ash 已经完成了测试,我们准备将全部流量切换到 ClickHouse。我们迁移了以下部分:
- 邮件报表
- 数据导出
- API 聚合端点
- 过滤器自动补全
- 移除 PageviewRepository,将数据导入改为写入 analytics_imported。EventRepository 也做了相同处理
- Site.php 中的 CalculateAnalyticsStartsAt 逻辑
在此特别感谢 Ash,在我忙于协调迁移工作的同时,他协助测试了上述全部内容。
完成这些低风险的 OLTP 表迁移后,我们继续将一些表结构迁移到 PlanetScale:
- site_integrations
- subscriptions
- subscription_items
- users
- user_referral_codes
- goals
- email_report_subscriber_sites
- email_report_subscribers
随后,我将另外几个表结构迁移至 ClickHouse:
- personal_access_token_performance
- referral_code_performance
并修改了应用代码,使其同时向 ClickHouse 中的这些表写入数据。
停机窗口
我极度抗拒服务中断。我总希望零停机。但当时已接近 2 月 27 日的末尾,我意识到在迁移那些动态变化的表时,维持应用在线并不现实。那时已是一天中流量较低的时间段。于是我将应用下线了不到 30 分钟。期间分析数据仍能正常写入,但仪表盘暂时无法访问。由于此时大多数客户已入睡,我们没有收到任何客户投诉。
剩余的 OLTP 表数据迁移包括:
- milestones(数据)
- personal_access_tokens(数据)
- site_integrations (data)
- subscriptions (data)
- subscription_items (data)
- users (data)
- user_referral_codes (data)
- data_exports_v2 (data)
- affiliate_payments (data)
- email_report_subscriber_sites
- email_report_subscribers
- sites (data)
- goals (data)
接着我对所有迁移过的表跑了一遍计数,把遗漏的(比如 goal 创建相关的)补拷过来,一切看起来都很顺利。
我把默认数据库连接切换到 PlanetScale,然后又把 ingest 的默认连接也切到 PlanetScale 用于读取。
最后我移除了 ingest 端点中所有与 SingleStore 的交互。至此,SingleStore 已从我们的应用中彻底清除。
再见,SingleStore
2026 年 2 月 28 日凌晨 2 点,我暂停了我们的 SingleStore 集群。一个时代结束了。
我保留了集群,以防需要恢复什么数据,但后来再也没有打开过它。
我想借这段文字表达对 SingleStore 过去五年支持的感激。接下来我要谈成本节省了,但在那之前,必须先郑重感谢 SingleStore 为我们做的一切。我不再把 SingleStore 看作面向小企业的数据库,而是把它看作一种非常成熟的数据库方案,适合需要真正兼顾 OLAP 和 OLTP 负载的用户。
第二天
3 月 1 日,客户纷纷称赞仪表盘速度飞快。大获成功。我醒来时有些疲惫,参加了一个家庭早餐会,之后一整天都在收拾行李,因为第二天要飞去英国。
成本节省
技术深挖文章不讲讲省了多少钱怎么行。先做几点说明:
- 这不是严格的 1:1 对比。我做出的一些基础设施决策降低了整体工作负载,所以不要把图中的数据当作等价对比来看,这只是我们前后的实际支出。
- 我对比了 SingleStore 与我们新数据库方案的按需定价。SingleStore 官网提到,签一年期合同可享受折扣。我们确实签了年付合同,但把这个价格公开不太合适。
具体成本明细如下:
有个有趣的事:我们最初打算把所有服务都重构到 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 的专长,他身兼技术写作、教育和软件工程多职,在软件开发方面经验丰富。
每月获取更多类似文章(大概吧)
订阅成为第一时间知道此类新文章发布的人。
订阅