秀才猜成语正版
26.11MB · 2025-11-20
2025 年 11 月 18 日,Cloudflare 发生了 2019 年以来最严重的一次全球网络故障。大批依赖 Cloudflare 的网站和服务(包括 ChatGPT、X、游戏、各类 SaaS)在数小时内持续返回 5xx 错误,用户看到的就是 “Cloudflare 网络内部出错” 的报错页。
事后 Cloudflare 发布了详细的事故报告:这 不是黑客攻击,而是一连串由 ClickHouse 数据库权限变更 引发的连锁反应,最终导致核心代理程序崩溃。
下文我们按这样几个问题来讲:
时间:
用户能感知到的症状:
注意:数据库本身没 “坏” ,ClickHouse 按文档规定的行为正确工作;问题在于上层特征生成逻辑对它做了错误假设。
要理解事故里那条坑人的 SQL,我们先快速认识一下 ClickHouse。
你可以把 ClickHouse 理解成一个:
这里我们只需要关心它的三点特性:
和 MySQL 一样,ClickHouse 有多个 database(库),每个库里有很多表。
但它还引入了 分布式表(Distributed) 的概念,用来统一查询分片数据。
在 Cloudflare 的架构里(根据官方描述):
default 数据库:放的是 Distributed 表,比如 default.http_requests_features,对用户来说是逻辑上的 “整张表”;r0 数据库:放的是实际分片上的 本地物理表,比如 r0.http_requests_features。也就是说:两边都有同名表,一个是聚合视图,一个是真实存储。
ClickHouse 有一套 system.* 表用来提供元数据,比如:
system.tables:有哪些表;system.columns:所有表里有哪些列。system.columns 的典型用法类似:
SELECT database, table, name, type
FROM system.columns
WHERE table = 'http_requests_features';
这有点像 MySQL 的 information_schema.columns。
关键点: system.columns 只会显示 当前用户有权限看到的表的列信息。如果你没权限访问某个库 / 表,那它在 system.columns 中就 “透明消失”,根本不会出现在结果里。
ClickHouse 支持角色 / 权限(RBAC):
可以针对:库、表、列、甚至行做 SELECT/INSERT 等权限控制;
典型授权语法:
GRANT SELECT ON default.* TO some_role;
GRANT SELECT ON r0.http_requests_features TO some_role;
也就是说,你给了什么权限,就会影响 system. 视图中用户能看到什么*。
Cloudflare 在报告里描述,他们想做一件 “看起来很合理” 的事:
为此,他们在 ClickHouse 上做了一个变更(简化理解,大致是这样):
之前:
GRANT SELECT ON default.* TO role_for_analytics;
-- 用户只看得到 default 库的表和列
之后:
GRANT SELECT ON default.* TO role_for_analytics;
GRANT SELECT ON r0.* TO role_for_analytics;
-- 用户现在也能看到 r0 库里的底层物理表
这个操作导致的效果是:
这本身是 ClickHouse 完全正常、符合文档的行为。
坑在上层: Cloudflare 的某段业务代码默认认为 “只会有 default 那一份”。
Cloudflare 的 Bot Management 需要为自己的机器学习模型生成一个 “特征配置文件(feature file)” ,里面列出各种特征名称、类型等。这个配置文件下发给全网的代理,用来给每个请求打 bot score。
在生成这个文件时,有这样一条 SQL(官方报告里给出来的):
SELECT name, type
FROM system.columns
WHERE table = 'http_requests_features'
ORDER BY name;
我们注意三件事:
table 过滤,没有管 database;default.http_requests_features;权限变更之前,system.columns 里对这个查询返回的,大概是这样的(示意):
| database | table | name | type |
|---|---|---|---|
| default | http_requests_features | feature_a | UInt8 |
| default | http_requests_features | feature_b | Float32 |
| … | … | … | … |
特征生成程序拿到这些行,生成一个有 N 个特征的文件,比如 60 个,完全在代理模块设定的 “最多 200 个特征” 上限之内。
一旦给了 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 就用,等于是:
于是,Bot Management 生成的特征配置文件大小直接 翻倍膨胀。
unwrap() panicCloudflare 的代理模块(尤其是新一代 FL2)里,对特征数量设了一个硬上限,例如 200(报告里提到此前通常只用到 ~60 个)。这个模块大致做了两件事:
问题就出在这条错误路径上:
Result 调用了 unwrap();Ok(...),不会出事;Err(...),unwrap() 直接 panic;而这恰好发生在 Cloudflare 最核心的流量路径上,于是就出现了我们看到的 “半个互联网都挂了” 的现象。
如果从架构和工程角度拆解,这条链路里至少有五个关键坑:
特征文件是 Cloudflare 自己内部管道生成的,不是用户直接上传的。工程师通常会下意识觉得:
于是:
但现实是:只要链路稍微复杂一点,“内部配置” 就需要像不可信输入一样对待。
那条 SQL 的隐含假设是:
而 ClickHouse 的行为定义是:
这其实是两套模型的思维冲突:
ClickHouse 的 RBAC 是全局影响的:
一旦你对 r0.* 授予 SELECT,用户就理所当然能看到 r0 下的表和 system.* 中相应条目;
但这次权限变更的决策路径,很可能只关注了 “子查询用实际用户身份执行” 的需求,没有系统性地梳理:
system.columns?database 做了区分?在高可用系统里,一般会尽量避免在核心路径中:
panic;unwrap()、expect() 之类会终止进程的方法。更理想的做法包括:
Cloudflare 在报告中也提到,今后会更严格地审查代理中各组件的 failure mode,并增加更多 “全局 kill switch”。
考虑到 Bot Management 是一个高度依赖配置的模块:
如果在 “每次发布新特征文件” 时就有监控:
那么这次问题可能在影响全网之前就被挡在实验 / 灰度阶段。
你完全可以把这次事故当成一个通用的 “系统设计反例” ,而不是仅仅归咎于某个数据库或某行 Rust 代码:
所有配置都要当 “不可信输入” 处理
system. / information_schema 这类元数据查询要写得 “保守” *
database = 'xxx';权限和元数据视图高度耦合时,变更要反推到所有依赖方
在核心组件里,错误优先 degrade 而不是 crash
监控不仅要看 “请求量、错误率”,也要看 “配置和元数据的健康度”
从 “改了一个数据库权限” 到 “全球大量网站 5xx”,中间隔着的是:
如果你在做日志平台、风控系统、埋点分析,或者任何依赖 “配置 + 大规模分布式服务” 的架构,这次 Cloudflare 的事故值得你认真读一遍原始报告,然后对照自己的系统问一句: