2025 年 11 月 18 日,Cloudflare 发生了 2019 年以来最严重的一次全球网络故障。大批依赖 Cloudflare 的网站和服务(包括 ChatGPT、X、游戏、各类 SaaS)在数小时内持续返回 5xx 错误,用户看到的就是 "Cloudflare 网络内部出错" 的报错页。
事后 Cloudflare 发布了详细的事故报告:这 不是黑客攻击 ,而是一连串由 ClickHouse 数据库权限变更 引发的连锁反应,最终导致核心代理程序崩溃。
下文我们按这样几个问题来讲:
- 这次事故表层上发生了什么?
- 技术上到底是怎样的一条 "多米诺骨牌链"?
- ClickHouse 权限变更 到底改了啥,为什么会出事?
- 我们能从中学到哪些通用的工程经验?
一、事故概览:从错误页到核心代理崩溃
1. 外在表现
-
时间:
- 11:20 UTC 左右:Cloudflare 网络开始大规模返回 5xx;
- 14:30 左右:核心流量基本恢复;
- 17:06:Cloudflare 宣布所有系统恢复正常。
-
用户能感知到的症状:
- 访问大量网站时,页面直接显示 Cloudflare 的错误页;
- 许多登录、验证码、人机校验界面完全无法加载(Turnstile 挂了);
- 一部分使用 Cloudflare Access / Zero Trust 的内部系统登录失效。
2. 高层技术原因(一句话版)
一次 ClickHouse 权限变更 → 查询 system.columns 行为变化 → 生成的 Bot Management 特征配置文件 "翻倍膨胀" → 超过代理模块设定的特征上限 → 新代理 FL2 里一段 Rust 代码
unwrap()触发 panic → 核心代理程序崩溃,向全网返回 5xx。
注意:数据库本身没 "坏" ,ClickHouse 按文档规定的行为正确工作;问题在于上层特征生成逻辑对它做了错误假设。
二、ClickHouse 是什么?(给没用过的人一个 3 分钟版)
要理解事故里那条坑人的 SQL,我们先快速认识一下 ClickHouse。
你可以把 ClickHouse 理解成一个:
高性能的列式分析数据库,专门用来存储和查询海量日志、监控、埋点数据。
这里我们只需要关心它的三点特性:
1. 多个 database & 多种表类型
和 MySQL 一样,ClickHouse 有多个 database(库),每个库里有很多表。
但它还引入了 分布式表(Distributed) 的概念,用来统一查询分片数据。
在 Cloudflare 的架构里(根据官方描述):
default数据库:放的是 Distributed 表 ,比如default.http_requests_features,对用户来说是逻辑上的 "整张表";r0数据库:放的是实际分片上的 本地物理表 ,比如r0.http_requests_features。
也就是说:两边都有同名表,一个是聚合视图,一个是真实存储。
2. system.* 系统表:类似 information_schema
ClickHouse 有一套 system.* 表用来提供元数据,比如:
system.tables:有哪些表;system.columns:所有表里有哪些列。
system.columns 的典型用法类似:
sql
SELECT database, table, name, type
FROM system.columns
WHERE table = 'http_requests_features';
这有点像 MySQL 的 information_schema.columns。
关键点: system.columns 只会显示 当前用户有权限看到的表的列信息 。如果你没权限访问某个库 / 表,那它在 system.columns 中就 "透明消失",根本不会出现在结果里。
3. RBAC 权限模型
ClickHouse 支持角色 / 权限(RBAC):
-
可以针对:库、表、列、甚至行做 SELECT/INSERT 等权限控制;
-
典型授权语法:
vbnetGRANT SELECT ON default.* TO some_role; GRANT SELECT ON r0.http_requests_features TO some_role;
也就是说,你给了什么权限,就会影响 system. 视图中用户能看到什么*。
三、那 Cloudflare 这次到底改了什么权限?
Cloudflare 在报告里描述,他们想做一件 "看起来很合理" 的事:
让运行分布式查询的不是单一的 "系统账号",而是发起请求的那个真实用户,这样限流、配额、权限控制都更细粒度。
为此,他们在 ClickHouse 上做了一个变更(简化理解,大致是这样):
之前:
vbnet
GRANT SELECT ON default.* TO role_for_analytics;
-- 用户只看得到 default 库的表和列
之后:
vbnet
GRANT SELECT ON default.* TO role_for_analytics;
GRANT SELECT ON r0.* TO role_for_analytics;
-- 用户现在也能看到 r0 库里的底层物理表
这个操作导致的效果是:
同一个用户在查询
system.columns时,原本只会看到default库的列,现在会同时看到default+r0两套数据。
这本身是 ClickHouse 完全正常、符合文档的行为。
坑在上层: Cloudflare 的某段业务代码默认认为 "只会有 default 那一份"。
四、那条 "坑死人不偿命" 的 SQL:特征文件如何被 "翻倍膨胀"
Cloudflare 的 Bot Management 需要为自己的机器学习模型生成一个 "特征配置文件(feature file)" ,里面列出各种特征名称、类型等。这个配置文件下发给全网的代理,用来给每个请求打 bot score。
在生成这个文件时,有这样一条 SQL(官方报告里给出来的):
sql
SELECT name, type
FROM system.columns
WHERE table = 'http_requests_features'
ORDER BY name;
我们注意三件事:
- 它只按
table过滤,没有管database; - 之前用户只看得到
default.http_requests_features; - 结果被直接用来拼装 "特征列表"。
1. 变更前:一切正常
权限变更之前,system.columns 里对这个查询返回的,大概是这样的(示意):
| database | table | name | type |
|---|---|---|---|
| default | http_requests_features | feature_a | UInt8 |
| default | http_requests_features | feature_b | Float32 |
| ... | ... | ... | ... |
特征生成程序拿到这些行,生成一个有 N 个特征的文件,比如 60 个,完全在代理模块设定的 "最多 200 个特征" 上限之内。
2. 变更后:同名表 + 权限 = 数据翻倍
一旦给了 r0.* 的权限,这条 SQL 的结果就变成了:
| database | table | name | type |
|---|---|---|---|
| default | http_requests_features | feature_a | UInt8 |
| default | http_requests_features | feature_b | Float32 |
| ... | ... | ... | ... |
| r0 | http_requests_features | feature_a | UInt8 |
| r0 | http_requests_features | feature_b | Float32 |
| ... | ... | ... | ... |
可以看到:同样的列在 default 和 r0 下各出现了一遍。
由于上层代码完全没看 database 字段,只拿 name、type 就用,等于是:
原有 N 条特征记录 → 现在变成了约 2N 条。
于是,Bot Management 生成的特征配置文件大小直接 翻倍膨胀。
3. 代理模块中的上限 + unwrap() panic
Cloudflare 的代理模块(尤其是新一代 FL2)里,对特征数量设了一个硬上限,例如 200(报告里提到此前通常只用到 ~60 个)。这个模块大致做了两件事:
- 按上限预分配存储特征的内存;
- 在加载配置时,如果发现特征条数超出了预期,就会走一条错误路径。
问题就出在这条错误路径上:
- 在 FL2 的 Rust 代码中,有地方对一个
Result调用了unwrap(); - 在正常情况下这是
Ok(...),不会出事; - 但当特征条数超出上限时,它变成了
Err(...),unwrap()直接panic; - panic 导致整个代理进程崩溃,无法处理请求,于是向外统一返回 5xx。
而这恰好发生在 Cloudflare 最核心的流量路径上,于是就出现了我们看到的 "半个互联网都挂了" 的现象。
五、这一链路里真正的几个 "坑点"
如果从架构和工程角度拆解,这条链路里至少有五个关键坑:
1. 把 "内部生成的配置" 当成了完全可信
特征文件是 Cloudflare 自己内部管道生成的,不是用户直接上传的。工程师通常会下意识觉得:
"这是我们自己系统生成的东西,应该是靠谱的。"
于是:
- 没有对特征数量做严格校验或硬性截断;
- 没有做好 "文件异常时的降级策略"(比如忽略多余特征 / 回退上一版本 / 关闭 Bot 模块)。
但现实是:只要链路稍微复杂一点,"内部配置" 就需要像不可信输入一样对待。
2. SQL 查询对系统表行为做了隐式假设
那条 SQL 的隐含假设是:
"我查
system.columns过滤某个 table 名,就只会返回 default 库那一份。"
而 ClickHouse 的行为定义是:
只要你对某个表有权限,它的列就会出现在
system.columns中,哪怕有多个库里有同名表。
这其实是两套模型的思维冲突:
- 业务侧:默认 "一个逻辑表名 → 一份元数据";
- DB 侧:允许 "多个 database + 多个同名表全都合法存在"。
3. 权限(RBAC)变更没有反推依赖系统
ClickHouse 的 RBAC 是全局影响的:
-
一旦你对
r0.*授予 SELECT,用户就理所当然能看到r0下的表和 system.* 中相应条目; -
但这次权限变更的决策路径,很可能只关注了 "子查询用实际用户身份执行" 的需求,没有系统性地梳理:
- 哪些上层流程依赖
system.columns? - 这些流程是否对
database做了区分? - 是否假设 "一张表名只有一份元数据"?
- 哪些上层流程依赖
4. 核心路径里用了 "会崩整个进程" 的错误处理方式
在高可用系统里,一般会尽量避免在核心路径中:
- 直接
panic; - 在错误分支调用
unwrap()、expect()之类会终止进程的方法。
更理想的做法包括:
- 对 "非核心功能" 模块的错误要能 fail-open / fail-closed 可配置;
- 更上层有一个 "熔断" 点,能把某些模块彻底摘除,而不是让它们把整个代理拉跨。
Cloudflare 在报告中也提到,今后会更严格地审查代理中各组件的 failure mode,并增加更多 "全局 kill switch"。
5. 缺少一眼就能看出 "feature 数量异常" 的监控
考虑到 Bot Management 是一个高度依赖配置的模块:
-
如果在 "每次发布新特征文件" 时就有监控:
- 统计特征数;
- 与前一版本做对比(例如超过 2 倍就报警);
-
那么这次问题可能在影响全网之前就被挡在实验 / 灰度阶段。
六、给不了解 ClickHouse 的读者的一点 "总结版教训"
你完全可以把这次事故当成一个通用的 "系统设计反例" ,而不是仅仅归咎于某个数据库或某行 Rust 代码:
-
所有配置都要当 "不可信输入" 处理
- 不只是用户表单;
- 包括内部管道生成的 JSON、特征文件、元数据快照......一旦链路足够长,它们随时可能和预期不一致。
-
system. / information_schema 这类元数据查询要写得 "保守" *
- 明确加上
database = 'xxx'; - 或者在应用层做去重与限制;
- 把 "只会有一份" 的假设写成显式条件,而不是隐含在 "现在刚好没有同名表" 的巧合里。
- 明确加上
-
权限和元数据视图高度耦合时,变更要反推到所有依赖方
- 给某个库加了 SELECT 权限,看似是 "给别人多了一点数据访问能力";
- 实际上改变的是:系统表 / 视图 / 报表 / 配置生成 等一整条链路的输入空间。
-
在核心组件里,错误优先 degrade 而不是 crash
- 例如:Bot 特征文件加载失败时,可以先把该模块暂时视为 "关闭",让流量以更弱的防护通过;
- 真要 panic,也尽量把 panic 控制在某个 "安全边界" 之内,而不是当场把整个代理干掉。
-
监控不仅要看 "请求量、错误率",也要看 "配置和元数据的健康度"
- 特征数、配置尺寸、字段数量、schema 变化,这些也是异常的重要信号。
七、结语
从 "改了一个数据库权限" 到 "全球大量网站 5xx",中间隔着的是:
- 元数据行为的细微改变;
- 业务代码对系统表的隐式假设;
- 不够防御性的错误处理;
- 对内部配置缺乏 "像不可信输入一样" 的敬畏。
如果你在做日志平台、风控系统、埋点分析,或者任何依赖 "配置 + 大规模分布式服务" 的架构,这次 Cloudflare 的事故值得你认真读一遍原始报告,然后对照自己的系统问一句:
"如果是我这套系统,只改一个权限或 schema,会不会也有类似的连锁反应?"