Featured image of post 第七话|从建表到圈选:Doris 在 CDP 场景的实战

第七话|从建表到圈选:Doris 在 CDP 场景的实战

上一章把 Doris 的存储与执行机制摆完,这一章换到使用侧:一个 SaaS 会员运营平台要拿 Doris 做人群圈选,建表的每个参数该怎么定、标签回流怎么灌、圈选查询为什么跑得动却不对。全文以一个 2 亿会员、500 家商户的示例场景为底座,所有耗时与体积数字来自作者本机 Apache Doris 2.1.11 单 FE 单 BE 集群的实测,机制解释以 2.1 官方文档为准;场景参数是示例设定,不是某个生产系统的真实数据。

系列 GYD(Get Your Distributed) 第 7 / 7 篇
主题 DorisOLAPCDP人群圈选性能调优

上一章讲机制:数据在磁盘上怎么摆、摆好的数据怎么算。这一章讲决定。同样是「建一张会员画像表」,模型选哪个、排序键放谁、分桶怎么分,官方文档给的都是原则和区间;原则怎么变成你手上那个数字,只有在真实负载形状上跑一遍才看得出来。

本章和后面几章的场景是同一个:一个 SaaS 会员运营平台,商户在页面上勾标签组合圈人群、看预估人数、把人群包推到投放渠道,人群包每天凌晨重算一遍。会员总量 2 亿,分布在 500 家商户下,标签列上百个且每家启用的子集不同(本文示例表建 40 个标签列,判据同构)。

数据与口径声明:场景参数(会员数、商户分布、标签数、日回流量)是示例设定,用来复现这类负载的形状,不是某个生产系统的真实数据。所有耗时、体积、扫描量数字来自作者本机一套 单 FE 单 BE 的 Doris 2.1.11 集群(4 核、24 GB),用同构数据实测。耗时一律取 FE 审计日志的 Time(ms)(服务端口径),并区分冷跑与预热后中位数——这两个数字差着一个数量级,不标口径的耗时没有意义。机制解释沿用上一章的口径,出自 2.1 官方文档。

一、把这张画像表立起来

先看四类动作对表的要求

圈选这类业务,落到数据上是四个动作,每个动作对存储形态提一个要求:

动作负载形状对表的要求
圈选任意标签列组合进 WHERE,命中从几百人到千万人谓词要能被定位能力接住,接不住就是扫描
预估人数同一套条件的 count(*),运营每改一个标签就点一次秒级返回,且并发承受得住
定时重算每天全量刷新会员标签写入形态决定这件事的代价
导出投放千万级 user_id 分页拉走有序、可游标翻页

会员画像表存的是「会员当前的样子」——等级、活跃度、累计消费、最近下单时间。这句话直接决定了第一个选择。

决定一:模型选主键,但要知道主键贵在哪

上一章讲过三种模型的契约:明细模型原样保留每一行,聚合模型落盘即合并,主键模型每个键只留最新一行。「当前状态」这个语义就是主键模型的定义,没有第二个候选。真正要算的账是:和「明细模型 + 每天全量重灌」这条替代路比,主键模型贵在哪、省在哪。

贵的那部分是存储。同一份 2 亿行 × 42 列的数据,本机建两张表各灌一遍:

明细模型  member_profile_dup   6.886 GB
主键 MoW  member_profile       7.746 GB   多 11%

多出来的 11% 是主键模型的两份内部结构:删除位图(记录哪些键的旧版本作废)和持久化索引(写入时定位同键旧行要用)。上一章说 MoW「写入时就地合并」,这句话的成本就在这儿——它得先能找到那个键,才能合并。

省的那部分是写入。业务上的「标签回流」不是重灌全表,而是每天有一部分会员的若干标签变了。假设一天要更新 3000 万会员的 3 个标签列,两条路的写法完全不同:

-- 主键模型 + MoW:部分列更新,只写这 3 列,其余列保持原值
SET enable_unique_key_partial_update = true;

INSERT INTO member_profile (tenant_id, user_id, level, rfm_score, last_order_time)
SELECT tenant_id, user_id, <新等级>, <新RFM>, <新下单时间>
FROM <上游算好的回流结果>
WHERE tenant_id = 1;

本机实跑的就是这个形态,只是把三个新值表达式换成了从原表算出来的替身(6 - level 之类),写入行数与列数与真实回流一致。

明细模型没有「按键更新几列」这个动作——它的契约就是原样保留,改一行只能把整行重新写一遍,或者重灌全表。本机实测这两笔账:

写法写入量耗时(本机实测)
主键 MoW 部分列更新3000 万行 × 3 列27.31 秒
明细模型全量重灌2 亿行 × 42 列3 分 02 秒

写入量差了约 93 倍,耗时只差 6.7 倍——这个不对称本身就是结论:主键模型省下的不是那两分钟,而是**「每天必须把 2 亿行全量重算并搬一遍」这件事**。上游标签计算的输出量、导入窗口占用的资源、重灌期间的版本压力,都随全量这两个字放大。

读侧这一项没有差别。同一条圈选查询在两张表上预热后的中位数都是 84 ms(明细表 84 / 主键表 84,各 6 次取中位)——上一章说 MoW 换来「读侧干净」,在这个数据量上它确实是干净的:计划里那个 __DORIS_DELETE_SIGN__ = 0 的谓词,成本量级可以忽略。

所以第一个决定的答案是:选主键模型,付 11% 的存储,换掉全量重灌这条路。代价不在读,在写侧的合并与那份位图。

决定二:排序键放 (tenant_id, user_id),然后接受它的局限

排序键必须是列定义的前 K 列(上一章的纪律),所以这一步同时决定了列的排列顺序。两个约束把它钉死了:

  • 圈选永远带租户——SaaS 的行级隔离靠 WHERE tenant_id = ?,它是所有查询的第一个条件,按上一章「最常过滤的定长列排前面」的建议,它当第一列;
  • user_id 当第二列,服务另一类动作:按会员主键查详情,以及导出时的有序游标翻页。
CREATE TABLE member_profile (
  tenant_id  BIGINT NOT NULL,        -- 排序键第 1 列:圈选必带的租户
  user_id    BIGINT NOT NULL,        -- 排序键第 2 列:点查与游标翻页
  city_code  INT,                    -- ┐
  level      TINYINT,                -- │ 示例表建了 40 个标签列
  lifecycle  TINYINT,                -- │ 生产常见上百个,这条判断不变:
  order_cnt_90d INT,                 -- │ 多的是列,少的是能进短 key 的位置
  total_amount  DECIMAL(12, 2),      -- │
  last_order_time DATETIME,          -- ┘
  ...                                  -- 其余标签列,类型同上
) ENGINE = OLAP
UNIQUE KEY(tenant_id, user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES ("replication_num" = "1", "enable_unique_key_merge_on_write" = "true");

短 key 上限 36 字节,tenant_id 8 + user_id 8 只占 16 字节,还剩 20 字节的空间——但没有值得放进来的第三列。把某个标签(比如 level)提上来,它确实能让「租户 + 等级」这一类条件走通前缀索引;代价是 user_id 被挤出连续前缀,按会员主键点查和导出翻页从此用不上前缀索引。而圈选的条件组合是任意的,任何一个单独标签都只服务其中一类查询——三个位置里,用「覆盖必带条件」和「主键访问路径」占掉两个,比赌某个标签常当首条件更划算。

于是接受它的局限。这条圈选语句在本机的执行计划与 profile:

SELECT count(*) FROM member_profile
WHERE tenant_id = 1 AND level >= 3 AND order_cnt_90d > 5 AND total_amount > 3000;

partitions=1/1            -- 只建了一个分区,没有可裁的东西
tablets=32/32             -- 32 个 tablet 全部进扫描
RawRowsRead      30,000,000   -- 从存储层读出 3000 万行 = 租户 1 的全部
RowsRead          1,825,506   -- 过滤后剩 182.6 万

前缀索引完成了它该做的那一段:2 亿行按租户收窄到 3000 万。剩下的三个标签条件,一个索引都没吃到,3000 万行全部读出来逐行判断。16 倍的读取放大就藏在这里——这也是为什么这条查询「跑得动,但不对」:84 ms 是 4 核单机把蛮力跑满的结果,不是定位能力的结果。

决定三:分桶列和桶数,一个服务均衡、一个不服务裁剪

分桶列选 user_id。理由很朴素:它是这张表里唯一分布均匀的列,租户 ID 不行(头部一家占 15%)、任何标签列更不行(上一章的高基数纪律)。

这个选择有一个容易被忽略的后果:圈选条件里没有 user_id,所以桶裁剪不会发生——上面计划里的 tablets=32/32 就是这个意思,2 亿行摊在 32 个桶上,任何一个桶都可能藏着目标行,只能全扫。这不是错误,是这个场景的固有形状:按主键点查能裁到 1 个桶,按标签圈选一个桶都裁不掉。分桶列在圈选场景里服务的是均衡与并行,不服务裁剪,指望它像 MySQL 索引那样「命中就少读」是错配。

桶数按上一章的官方口径估:单个 tablet 保持在 1G–10G。这张表全量约 7.7 GB,照这个区间,8 个桶(每 tablet 约 970 MB)比 32 个桶(每 tablet 248 MB)更贴近建议值。本机把 8 桶版本也建了一遍,两笔可查的账:

32 桶8 桶
每 tablet248 MB / 625 万行970 MB / 2500 万行
全表体积7.746 GB7.549 GB

碎桶多花的 2% 是实测数字,归因是我按机制推的——每个 tablet 各自带一份短 key 索引与 page 级元数据,切得越碎这份固定开销越多;我测到的只有这两个体积数本身。桶数少一半还有另一笔对称的账:副本迁移、Schema Change 这类要重写 tablet 的操作,单次代价就大一倍(上一章讲过)。

桶数对查询耗时有多敏感,这台机器测不出可信答案:本机 BE 只有 4 核,parallel_pipeline_task_num 默认取核数一半,也就是 2 个扫描实例——8 桶版本的同一条圈选预热后中位数 117 ms、32 桶 84 ms,这个差值里混着缓存、Compaction 时机和调度抖动;而生产集群的 BE 核数通常远高于 4,扫描实例数不是一个量级,把这个差值外推过去必然失真。所以这里只留一句判断:桶数按 tablet 目标大小倒推,不要按「多切几块并行快点」的直觉定,后者在 2.1 的 pipeline 引擎下已经不是主要收益来源(上一章的分片内并发读讲过原因)。

三个决定签完之后

把这一章的账并排放:模型选了主键,付 11% 存储、省掉全量重灌;排序键选了 (租户, 会员),换来必带条件能裁到 3000 万,代价是标签条件零索引、16 倍蛮力;分桶选了 user_id,换来均匀分布,代价是圈选时一个桶都裁不掉。

三个决定都没有「错」的那个,但每一个都把成本挪了个位置。而圈选这条查询真正的问题——读 3000 万只为留 182.6 万——不在建表里,在标签列的索引形态里。

二、第一处不匹配:谓词与落盘顺序

上一章末尾那条圈选读 3000 万行、留 182.6 万行。这一章把它补上,补法有两种——跳数索引和点查索引——但先用实测说清一件事:在这张表上,第一种基本是白付的。

测量口径:这一章的主证据是 query profile 里的 RawRowsRead(从存储层真实读出的行数),不是耗时。原因是同一台机器上同一条查询冷跑 309 ms、热跑 33 ms,耗时里混着缓存与调度;而 RawRowsRead 是个确定量,它掉不下来就是没生效。

先量清楚:三条查询各自在蛮力什么

2 亿行、无任何用户索引的基线(命中行数是 SQL 的真实返回):

圈选条件读出留下放大
tenant_id=1 AND city_code=1013000 万6.0 万499×
tenant_id=1 AND level>=3 AND order_cnt_90d>5 AND total_amount>30003000 万182.6 万16×
tenant_id=1 AND is_vip=13000 万1049.8 万3×

三条读的都是 3000 万——前缀索引把租户那一段裁完之后,标签条件一律逐行判断。它们的差别只在留下多少:放大 499 倍的那条最该救,放大 3 倍的那条救了也没多少可省。这个「留下多少」可以量化,就是各列的单值命中占比:city_code 有 500 个取值、最大值的占比 0.2%;is_vip 只有 2 个取值、最大占比 65%。这两个数是下面所有判断的起点。

第一种补法:bloom filter,一次干净的白付

city_code 是等值查询、基数不低,看起来正是上一章说的 bloom filter 的适用场景。加上它:

ALTER TABLE member_profile SET ("bloom_filter_columns" = "city_code");

命令 0.11 秒返回,后台的 schema change 作业在 2 亿行上跑了约 80 秒才 FINISHED,存储从 8.450 GB 涨到 8.581 GB(+131 MB)。然后重跑那条 city_code=101 的查询:

RawRowsRead = 30,000,000   ← 一行没少读
RowsRead    =     60,138
放大         = 499×          ← 与加索引前完全相同

一点没变。原因在上一章讲过的那条纪律上:bloom filter 是跳数索引,它只对「这一页里有没有这个值」回答「没有」,从而整页跳过。而这张表按 (tenant_id, user_id) 排序,city_code 是随用户哈希随机散布的——租户 1 的 3000 万行里,每一个 page 都装着全部 500 个城市码。过滤器于是永远回答「可能有」,一个页也跳不掉。

所以判据不是「等值 + 高基数就建 bloom filter」,而是:这一列的取值在页之间是否有偏斜。有偏斜的列,是那些与排序键共序、或本身按时间单调写入的列(比如 last_order_time,同一批回流的数据时间聚在一处);标签列按用户随机分布时,跳数索引没有饭吃。

第二种补法:倒排索引,它不吃分布

倒排索引走的是另一条路:不给「这页有没有」的答案,直接给哪些行命中的行号。所以列值怎么散布与它无关。

CREATE INDEX idx_city ON member_profile (city_code) USING INVERTED;
BUILD INDEX idx_city ON member_profile;   -- 事后加的索引要对已有数据补建一次

BUILD INDEX 在 2 亿行上跑了 804 秒,这一列的索引多占 412 MB。重跑同一条查询:

RawRowsRead = 60,138   ← 从 3000 万降到与命中行数相等
RowsRead    = 60,138
放大         = 1×

499 倍的蛮力一次消掉,热态耗时 33 ms。但别急着给所有标签列都建——换一条命中面大的条件,同一个索引立刻失效:

tenant_id=1 AND city_code BETWEEN 100 AND 275   -- 覆盖 35% 的取值
RawRowsRead = 30,000,000   ← 又回到全读
RowsRead    = 10,562,749

命中面铺到三分之一时,索引没有省下任何读取。按机制推,是因为「按索引逐行取位 + 随机读」在命中行足够多时比不过顺序扫全量(这一句是推演,我测到的只是 RawRowsRead 没变)。这与上一章那条「倒排的收益来自命中少,不是无条件快」是同一件事的两面:索引省下的读取量,上限就是你不命中的那部分行数。

组合条件的木桶:第一个没索引的条件决定下限

真实圈选从来不只一个条件。把索引列和没索引的列混起来,看谁说了算:

tenant_id=1 AND city_code=101 AND level>=3
  RawRowsRead = 60,138   RowsRead = 13,150   放大 5×

city_code 上的索引把读取钉死在 6 万行,level 没有索引,就在这 6 万行里逐行筛掉 78%——它筛得动,因为基数已经被前一个条件压下去了。反过来,把有索引的那一列拿掉:

tenant_id=1 AND level>=3 AND order_cnt_90d>5 AND total_amount>3000
  RawRowsRead = 30,000,000   放大 16×        ← 索引一点没帮上

结论很硬:一条圈选里,第一个没有索引的条件,决定这条查询的读取量下限。索引建在哪个列上不重要,重要的是运营圈人时最先出现、且命中占比最低的那几个列有没有被覆盖到。

那到底给哪几列建

三个筛子按顺序过:

  1. 进条件的频率。别靠猜——Doris 的 FE 审计日志记录了每一条查询的原文,按周统计一次各标签列出现在 WHERE 里的次数,就是运营的真实习惯。
  2. 单值命中占比。上面那两个数(0.2% 与 65%)就是筛子:占比低的列建了才可能省,占比高的列建了也省不动。一条 SQL 就能量出来:
SELECT max(c) AS top_value_rows, sum(c) AS tenant_rows
FROM (SELECT city_code, count(*) c FROM member_profile
      WHERE tenant_id = 1 GROUP BY city_code) x;
  1. 预算。这一项在 2 亿行上是硬的:每列一次 BUILD INDEX 约 13 分钟、约 412 MB 存储。40 个标签列全建,就是 9 小时窗口和 16 GB 索引——荒谬。所以真实动作是从高频且低占比的列里挑 5 到 8 个,其余列接受「在已筛剩的行上逐行判断」,只要前面那一两个索引把基数压得够低,后面的逐行过滤并不贵。

顺带一句运维量级:这些索引作业都是异步的、且互斥(上一章讲过 SCHEMA_CHANGE 状态会挡住其他结构变更)。在 2 亿行的表上,「加一列索引」意味着一个 13 分钟的窗口和一次不能并发的变更——这是它和 MySQL 上加个索引完全不同的地方。

三、第二处不匹配:写入节奏与合并节奏

前两章都在动「读」这一侧。但这类系统每天真正压着集群的是写:标签要回流,行为事件要持续进来。上一章建立过一个等式——一次导入 = 一个不可变的 rowset = 一个版本,读的时候要把这些版本叠成一张视图。这个等式在导入频率高的时候会开始收利息。

先量一遍「一次导入一个版本」有多贵

一张 4 桶的小表,连灌 24 条单行 INSERT,tablet 的 Version 从 3500 涨到 3524(重跑一轮是 3553 → 3577):精确的 24。没有合并、没有优化,一条语句一个版本。

大表上单笔大写入反而是便宜的——第一章那次 3000 万行的部分列更新,27.31 秒完成,版本只 +1。贵的是高频小批:每次导入都留下一个待合并的 rowset,读的时候就要多叠一层。同一个圈选查询,在这次更新刚落地后的头两次跑出了 502 ms 和 655 ms,随后回落到 81 ms 和 65 ms。多出来的那一段,按机制推是新 rowset 首次读取(缓存未热)加上合并路径变长——我测到的是这四个数本身,归因是推的。

-235 的真实成因不是「导入次数多」

版本堆到有上限,导入就会被拒绝,报 -235。但这里有两处口径要先说清,因为它们直接影响你怎么设防线。

第一,默认值文档自己不一致。官方 FAQ「数据操作问题」页写的是「默认 500」,而 BE 配置项文档页和本机 2.1.11 的实际配置都是 2000(且可动态修改)。以本机为准:max_tablet_version_num = 2000。

第二,也是更要紧的:这个限制判的不是版本号。SHOW TABLETS FROM 表 里有两个容易混的字段——Version 是这条 tablet 的时间线走到第几号,只增不减;VersionCount 是当前还并存的 rowset 份数,会被合并压回去。撞 -235 判的是后者。

这一点有直接证据。往同一个 tablet 连灌 24 条单行 INSERT,逐条采样:Version 从 3553 一路走到 3577(每条 +1,精确 24),而 VersionCount 自始至终钉在 5——这张小表的合并是毫秒级的,攒不起来。此时 Version 早已越过 2000 这个阈值 1500 多,导入一次都没被拒。反向实验指向同一处:把阈值临时调到 200、再调到 8,Version 涨到 3500 仍不报错;要是判的是版本号,调到 8 之后的第二次导入就该失败。

同一个 tablet 上 Version 一路涨、VersionCount 恒为 5,检查比的是后者

所以「导入次数多就会撞 -235」是错的。事故条件是导入速度大于合并速度,而这恰恰发生在大表上:一个 248 MB 的 tablet 合并要秒级,此时高并发小批导入就会把 rowset 堆起来。小表反而几乎撞不到——这也是为什么这类事故常在业务增长、tablet 变大之后才第一次出现。

排查动作按顺序:SHOW TABLETS FROM 表 看每个 tablet 的 Version;BE 日志里搜 compaction 与该 tablet id,看合并是否积压;导入侧看 Stream Load 返回 JSON 里的时间分解(上一章讲过),确认是卡在写还是卡在等版本发布。

解法与代价:攒批的合并率来自并发

Doris 2.1 的 Group Commit 是官方给的答案:把多个小事务在存储侧合并提交。但「合到什么程度」取决于同一时刻落在同一个 tablet 上的导入数,这一点只有实测说得清。四组配置,同一张小表:

配置并发导入条数版本增量墙钟可见性
默认(不开)串行24+24约 0.5 s/条立即
async_mode串行30+1秒级返回约 12 秒后可见
sync_mode串行20+20202 秒立即
sync_mode8 会话并发40+551 秒立即

第二行和第三行的对比最值得记:同样串行,async_mode 把 30 次导入合成 1 个版本,sync_mode 一个都没合,还让每条都等了约 10 秒的提交窗口。原因是 async_mode 客户端不等提交就返回,后续导入于是落进同一个窗口;而 sync_mode 要等本次提交完成才返回,串行时窗口里永远只有自己。第四行验证了另一半:并发一开,sync_mode 40 条只产生 5 个版本。

语法上有个坑要标出来:group_commit 是会话变量,不是表属性——ALTER TABLE ... SET ("group_commit" = "async_mode") 会直接报 Unknown table property,正确写法是:

SET group_commit = async_mode;   -- 会话级;三种取值:off_mode / sync_mode / async_mode

圈选场景该开哪一档

有了上面那组数字,选择就变成业务问题了:能不能接受写入后延迟可见。

  • 预估人数、人群包定时重算:晚 10 秒知道结果完全可接受,甚至更好(重算期间不该被读到中间状态)。这一类走 async_mode,把版本压力直接压掉一个数量级。
  • 投放前最后一次确认、部分列更新后立刻查:不可接受。这一类要么保持默认(自己把批量攒大,比如按分片攒到百万级再灌),要么用 sync_mode 并且真的并发写——串行 + sync_mode 是实测里最坏的组合,既没省版本又赔了时延。

再往回退一步看这件事:合并能力是有预算的,它吃的是 BE 的 CPU 和磁盘 IO。提高导入频率,本质是借未来的合并算力换现在的低延迟。所以「我们的导入能不能更频繁」这个问题,正确的问法是「我们的合并还追不追得上」——SHOW TABLETS 里的版本号和 BE 日志里的 compaction 记录,就是它的仪表盘。

四、第三处不匹配:人群包形态与集合运算

前三章都在处理「一张表怎么被查、怎么写」。这一章换到业务对象上:圈选跑出来的结果本身要怎么存。

CDP 里人群包(audience)在语义上就是一个用户 ID 集合,但仓库里的第一版实现通常是一张明细结果表——pack_id + user_id,一行一个成员。它最直观:能数、能 JOIN、能分页拉走。等到要做「两个人群包求交」「在包上再叠一个标签条件」「排除风控名单后再并上某个渠道包」这类操作时,才会发现集合运算在关系表上就是一串 JOIN,而每一个 JOIN 都要把成员重新读出来、重新装进哈希表(哈希表是 Java 侧最熟的结构:把一批值按哈希放进桶里,用 O(1) 判断某个值在不在)。

Doris 给了另一条路:BITMAP 聚合列——把整个成员集合压进一行的一个二进制列里,集合运算退化成位运算。这条路到底省了什么、又在哪里付出代价,只能用同一批人群包在两种形态上跑同一组操作来回答。

本章的对比在两张表上做,成员列都是 BIGINT:

-- 明细结果表:一行一个成员,成员 ID 进排序键
CREATE TABLE audience_member (
  pack_id    BIGINT NOT NULL,
  user_id    BIGINT NOT NULL,
  tenant_id  BIGINT,
  version    BIGINT
) ENGINE = OLAP
DUPLICATE KEY(pack_id, user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 8
PROPERTIES ("replication_num" = "1");

-- 位图聚合表:一个包一行,成员压进一个 BITMAP 列
CREATE TABLE audience_bitmap (
  tenant_id  BIGINT NOT NULL,
  pack_id    BIGINT NOT NULL,
  user_bm    BITMAP BITMAP_UNION
) ENGINE = OLAP
AGGREGATE KEY(tenant_id, pack_id)
DISTRIBUTED BY HASH(pack_id) BUCKETS 4
PROPERTIES ("replication_num" = "1");

第二段是 Doris 聚合模型的写法:列声明时跟在类型后面挂聚合函数,user_bm BITMAP BITMAP_UNION 表示同一个聚合键下新来的位图和已有的取并。(这里踩过一个语法坑:user_bm BITMAP_UNION——省掉类型只写聚合函数——在 2.1 直接报语法错,必须写全 BITMAP BITMAP_UNION。)

四个包,以及它们的真值

四个包都属租户 1(3000 万会员),规模跨两个数量级——这类系统的真实分布就是这样,头部活动圈千万级,长尾条件只圈几万:

包 ID圈选条件成员数(真值)
9001tenant_id=1 AND is_vip=110,497,584
9002tenant_id=1 AND active_30d=18,401,829
9003tenant_id=1 AND level>=42,097,972
9004tenant_id=1 AND city_code=10160,138

「真值」这一列很重要:它由一次单表扫描直接数出来,后面两种形态算出的任何成员数都必须能对上它。形态对比的前提是双方都算对,否则比的只是谁错得更隐蔽。

位图形态有一个硬前提:成员 ID 必须是整型。2.1 官方文档的原话是「Doris 使用 RoaringBitmap 实现精确去重」,且只对 TINYINT/SMALLINT/INT/BIGINT 生效,其他类型「需要构建全局字典」。这里的 user_id 恰好是 1 到 2 亿的连续整型,所以两张表都能直接用;真实系统的会员标识经常是 openid、手机号这类字符串,这个前提就得单独处理。

构建:贵的是扫描,形态只影响内存

两种形态都用 INSERT INTO ... SELECT 从画像表生成同一批包,耗时取审计日志 Time(ms)(热跑,服务端),内存取同一条日志的 PeakMemoryBytes:

成员数明细表位图表
10,497,5841816 ms / 643 MB1915 ms / 94.6 MB
8,401,829913 ms / 611 MB1280 ms / 85.0 MB
2,097,972262 ms / 134 MB333 ms / 57.3 MB
60,13892 ms / 6.7 MB102 ms / 6.3 MB

位图不但没更快,还慢 5% 到 40%。因为构建的成本主体是「扫 2 亿行、命中一千万行」,这一段两种形态完全一样;位图多出来的时间是逐行把整型值塞进压缩容器的开销。所以「换成位图,圈选就快了」在构建这一步不成立——这句话常被写成选型理由,实测不支持。

不对称的地方在内存:明细侧要把 1050 万行从扫描算子搬到写入算子,峰值 643 MB;位图侧同一个位置只搬一个聚合值,94.6 MB,差 6.8 倍。这笔内存会随导入并发线性叠加(此句是按机制推的,本机没有做并发导入压测),是并发排队时的真实约束。

存储:位图没有比明细省——省不省取决于成员 ID 在不在排序键里

这是本章最反直觉的一个结果,所以做了控制变量的对照。同一批 10,497,584 个成员、单个 tablet,绕开 FE 统计缓存(SHOW DATA 有约 1 分钟的延迟),直接量 BE 上 tablet 目录的磁盘占用:

形态(同一批 1050 万成员,1 桶)磁盘占用每成员
明细表,成员 ID 在排序键内(自然升序)16,568,475 B = 15.79 MB1.58 B
明细表,成员 ID 在排序键内(值被打乱)16,809,834 B = 16.03 MB1.60 B
明细表,成员 ID 挪出排序键当值列(打乱)37,401,273 B = 35.67 MB3.56 B
位图聚合表21,132,945 B = 20.15 MB2.01 B
HLL 聚合表101,704 B = 99.3 KB0.01 B

成员 ID 进了排序键的那张明细表,比位图还小 21%。要理解这件事得看两边各自在压什么。

位图这边,Doris 的 BITMAP 列内部存的是 Roaring bitmap。它的通用做法(Roaring 自己的公开设计,不是 Doris 特有)是:把整数按每 65536 个值切成一块,块内值少就用「数组容器」逐个存 2 字节,值多就改用「位图容器」——固定 8 KB、一位代表一个值,切换阈值是 4096。这批包在 2 亿值域上的密度只有 5%,2 亿 / 65536 ≈ 3052 个块,每块摊到约 3440 个成员,正好留在数组容器区间。按这个规则估是 2.0 字节/成员,实测 2.01 字节。换句话说,这份位图完全没有「压」,它就是把每个成员存成 2 个字节的数组。

明细表这边,上一章讲过存储层级:同一个 tablet 内数据按排序键有序落盘,官方文档对明细模型的表述是「建表时指定的 Duplicate Key 决定了排序与存储的列」。排好序的整型列相邻差值很小,delta 编码 + bit-packing 能把一个 int64 压到 1~2 字节——实测 1.58 字节/行。同样是 1050 万个整型 ID,排好序的明细表和数组容器状态的位图,压缩率在同一个量级;前者还略优。

「排序键里放不放」才是那个变量,这一点单独做了对照实验来证明。把成员 ID 用一个同余映射打乱:(user_id * 7919) % 200000003(7919 与 200000003 互质,且所有 user_id 都小于这个模数,所以映射在取值域上是单射,成员数不会因碰撞而变——实测打乱后仍是 10,497,584 个不同值)。然后写进两张表:一张仍让 user_id 参与排序键,另一张把排序键换成一个常量列、让 user_id 退成普通值列(于是落盘顺序 = 插入顺序,也就是打乱后的顺序)。

结果是:同一样一批值,放进排序键 16.03 MB,挪出来 35.67 MB,差 2.2 倍。而打乱输入顺序对「放进排序键」那一版几乎没有影响(15.79 → 16.03 MB),因为键内序列一定会被排好——决定体积的是排序这件事本身,不是值长什么样。

这条不只对人群包表有用:它解释了「为什么把高频出现在 WHERE 里的整型列放进排序键,连存储都一起省了」——排序键同时买到的是前缀索引 + 有序落盘带来的编码收益。

两个规模端的补充:60,138 人的小包,明细 149,121 B、位图 145,398 B,打平;四个包合到一张表的整表口径,明细 SHOW DATA 40.015 MB、位图 40.357 MB,也一样。

如果只要「人数」不要「成员」,还有第三档:HLL(HyperLogLog,一种只保存基数草图的类型,不管来多少个值,占用的空间基本不变;对应聚合函数 HLL_UNION)。同一个 1050 万成员的包,HLL 列实测 99.3 KB,比位图小 208 倍,估出来的人数是 10,535,810、误差 +0.364%。千分之一的空间换 0.4% 的人数误差,只用于看板上的规模趋势时它比位图更划算;代价是它只能求并——交集和差集都算不了。

集合运算:这才是真正的分水岭

四种运算,两种形态,结果全部与真值一致,差别只在代价(耗时与峰值内存同上口径,热跑):

集合运算真值明细表位图
A ∩ B2,941,079493 ms / 189.9 MB273 ms / 0.74 MB
A ∪ B15,958,3341400 ms / 1.39 GB219 ms / 0.12 MB
A − B7,556,505716 ms / 259.9 MB208 ms / 0.11 MB
(A ∪ B) ∩ C − D1,113,8291874 ms / 0.96 GB309 ms / 0.41 MB

耗时差 1.8 到 6.4 倍,峰值内存差 256 倍到 1.2 万倍——内存才是这一节的分水岭。

机制上没有悬念。明细形态做集合运算,要把两侧成员读出来、装进哈希表——count(DISTINCT user_id) 求并那行为 1596 万个不同值各维护一个条目,1.39 GB 就是这么来的,而且中间结果仍然是行。位图形态两侧各是一个二进制容器,bitmap_and / bitmap_or / bitmap_andnot 在容器上逐段做与或非,中间结果还是位图,整个查询从头到尾只有一行数据在算子之间流动。

明细形态把两侧成员装进哈希表,位图形态只在容器上逐段运算

位图侧那条四层嵌套的表达式长这样:

SELECT bitmap_andnot_count(
         bitmap_and(
           bitmap_or(a.bm, b.bm),   -- A ∪ B
           c.bm),                   -- ∩ C
         d.bm)                      -- − D
FROM (SELECT user_bm bm FROM audience_bitmap WHERE pack_id = 9001) a,
     (SELECT user_bm bm FROM audience_bitmap WHERE pack_id = 9002) b,
     (SELECT user_bm bm FROM audience_bitmap WHERE pack_id = 9003) c,
     (SELECT user_bm bm FROM audience_bitmap WHERE pack_id = 9004) d;

明细侧的等价写法是三段 UNION + JOIN + 反连接嵌套,这里不贴——它长不是问题,问题是每多一层就把成员多搬一遍。真实业务里的「排除已购买人群、排除风控名单、再和某个渠道包求交」就是这个深度。

顺带一个写法层面的实测:同样是 A − B,LEFT JOIN ... IS NULL 要 716 ms,NOT IN 子查询 233 ms,差 3.1 倍,内存都是 259.9 MB。差异在中间结果——前者先产出 1050 万行带 NULL 列的宽结果再过滤,后者直接在反连接里计数。用 NOT IN 的前提是被比较列非空(这里 user_id 是 NOT NULL);换成可空列,三值逻辑会让结果整个变空,这个坑在 Doris 上和 MySQL 完全一样。

回表:位图不吃亏

集合运算之外,人群包最高频的用法是「拿包去画像表取字段」——投放名单要带渠道、要带最近下单时间。同一个问题(pack 9003 里有多少 VIP,真值 733,742)三种写法:

写法耗时峰值内存
明细包表 JOIN 画像表691 ms243.1 MB
user_id IN (子查询)914 ms243.1 MB
位图展开成行后 JOIN 画像表432 ms72.2 MB

位图这一路把整包先还原成数组再炸开行(LATERAL VIEW explode(bitmap_to_array(...)),2.1 的 Nereids 优化器不吃 UNNEST(...) 那套 ANSI 写法,实测会报语法错,只有 Hive 风格的 LATERAL VIEW explode 能用),省掉了「从一张 210 万行的表里把 ID 一行行解码出来」的开销。所以「位图查详情一定要付额外代价」这个直觉,在本机数据里不成立——它反而是三者最省的。

导出:唯一稳定吃亏的地方

要真正把成员一行一行交出去时,位图开始付出代价。同一份 1050 万成员写进同一张落地表:

导出路径(同一目标表、同一 1050 万行)耗时
从明细包表读1084 ms
从位图展开后读1615 ms

多出的 49% 就是「反序列化位图 → 构造 ARRAY<BIGINT> → 再炸开」这条链;单独量这一步,把整包 1050 万个成员炸成行要 643 ms、288 MB。方向是固定的:位图形态在导出上不会比明细便宜,而且规模越大越亏。

分批游标导出更明显。明细表按 (pack_id, user_id) 排序,游标翻页是前缀索引直接定位:

SELECT user_id FROM audience_member
WHERE pack_id = 9001 AND user_id > <上一页末值>
ORDER BY user_id LIMIT 100000;      -- 实测 267 ms,取回 10 万行

位图侧只有区间能力,没有「按顺序取第 N 到 M 个」的稳定分页语义。bitmap_subset_in_range 可以做区间计数——实测 [50000000, 60000000) 得 524,605,与明细表上等价 WHERE 的结果一致;边界口径是小位图单独验过的:{1,2,3,4,5} 上 [2,4) 数出 2 个,即左闭右开。真要交明细,还是得整包炸开再自己分页,那比在明细表上翻页贵得多。

导出是明细形态的主场。

两个会静默出错的坑

坑一:字符串 ID 进位图不报错,给你一个空集合。

CDP 的成员标识经常是 openid、手机号、雪花 ID 字符串,而 to_bitmap() 只接受整型。传字符串时 Doris 做隐式转换,转不动就返回空位图,没有任何报错:

SELECT bitmap_count(to_bitmap('P3'));    -- 0
SELECT bitmap_count(to_bitmap('123'));   -- 1,纯数字字符串能转

(第一条是在一个 pref_category 列取值为 t_11、t_5 这类编码的表上验证的:整列 to_bitmap 之后 bitmap_count 全是 0。)

于是整条圈选链路上你会拿到一个「人人都是 0」的人群包,作业状态 OK。两条出路:

  • 按官方口径建那张「全局字典」,把字符串 ID 映射成稠密自增整型。这是位图方案真实的前置成本——字典表要维护增量、要能重放,本文的 user_id 恰好已经是 1~2 亿的连续整型,所以省掉了这一步。
  • 用 bitmap_hash64() 把字符串直接哈希成 64 位。实测同一个 60,138 成员的集合,bitmap_union_count(bitmap_hash64(CAST(user_id AS STRING))) 和 bitmap_union_count(to_bitmap(user_id)) 都数出 60,138,64 位哈希在这个量级没有可感知碰撞。代价是哈希值不可逆,导出明细时必须回到原 ID,等于又得留一张映射表。

坑二:重算人群包时不删旧位图,人数静默虚高 7 倍。

BITMAP_UNION 是聚合函数,只会把新位图并进旧的,不会剔除任何成员。让 pack 9003 重算一次——条件从 level>=4 收紧到 level>=5,真值从 2,097,972 降到 298,562:

-- 直接灌新结果,没有先删
INSERT INTO audience_bitmap
SELECT tenant_id, 9003, to_bitmap(user_id)
FROM member_profile WHERE tenant_id = 1 AND level >= 5;   -- 269 ms,State=OK

SELECT bitmap_count(user_bm) FROM audience_bitmap WHERE pack_id = 9003;
-- 2097972

因为新集合恰好是旧集合的子集,并完之后就是旧集合本身:报告人数 2,097,972,是真值的 7.03 倍,全链路没有一处错误。明细形态不会有这个问题——它一行一成员,重算就是换一批行,多出来的旧成员一眼能看见。

位图侧必须「先删干净再灌」:

DELETE FROM audience_bitmap WHERE pack_id = 9003;   -- 118 ms
-- 再灌新结果                                          205 ms

删完重灌后 bitmap_count 回到 298,562,与真值一致。明细侧同一次重算是 DELETE 46 ms + INSERT 241 ms。两侧总耗时几乎一样(323 ms vs 287 ms),差别在漏掉那一步的后果:位图侧漏 DELETE 是静默错数,明细侧漏 DELETE 会立刻以重复行的形式暴露出来。所以定时重算的调度里,删除和写入必须绑成同一个作业的两步,而不是只挂一个「跑圈选 SQL」。

第三章把版本与合并的账算过,这里补一笔删除的账:DELETE 只写删除标记,不回收物理空间。用 pack_dump 这张 1050 万行的表做了一次干净对照——删掉一半(WHERE user_id < 100000000,40 ms),可见行数从 10,497,584 变成 5,249,303,BE 上这个 tablet 的磁盘占用是 18.84 MB → 18.84 MB,一字节没少。物理回收要等合并把带标记的行真正丢掉,本文的观察窗口内没有发生。所以「每天重算一次人群包」对存储的影响是累加的:白天的删除标记会一直占着空间,直到合并追上。这件事在做磁盘容量预算时得单独算一行。

落地判据:谁负责算,谁负责交

把本章的实测摊平,选择变成四条可判的规则:

判断依据走向
包成员 ID 是不是整型(或愿意为它建字典表)不是整型且不想建映射:bitmap_hash64 或直接退回明细表
这批包的典型操作是计数与集合运算,还是导出明细计数/交并差为主:位图,最多省 1.2 万倍内存;导出为主:明细表,省 49%
有没有「放排序键」的位置可给成员 ID有:明细表存成员本身不比位图贵(实测 1.58 vs 2.01 字节/成员);没有:位图反超 1.8 倍
人数能容忍千分之几误差吗能(只做趋势看板):HLL,比位图小 208 倍,但只能求并

我的落地方案是两者并存、各管一段:位图表负责「算」——预估、交并差、多层组合;明细表负责「交」——投放名单导出、快照版本留痕。两张表从同一条圈选 SQL 生成,构建耗时几乎相同(1816 ms vs 1915 ms),多存一份的收益远大于省一份的开销。真正被实测推翻的,是「用位图替掉明细表能省存储、还能提速」这个开工前的预期:省的是集合运算的内存,不是存储、也不是构建时间。

五、把三处不匹配收成一张表

前四章是分开量下来的,但它们其实是同一件事的三个投影:Doris 的每一项定位能力,都建立在「事先挑出少数几列、由这几列决定数据物理顺序」之上。而圈选这类负载的形状是「任意列组合 × 大值域 × 集合运算」,正好和这个前提错开。三处不匹配分别落在谓词、写入节奏、结果形态上,每一处的解法都不是调参数,而是换一个更能接住这个动作的形态。

写入侧 → 存储侧 → 读取侧 → 结果侧一条链,三处错位标在各自的接缝上

落地判断表

要定的事看什么信号付什么代价本章哪一节量过
数据模型「当前状态」语义 → 主键;只要历史 → 明细主键比明细多 11% 存储(7.746 vs 6.886 GB);换来部分列更新 27.31 s,替掉 3 分 02 秒的全量重灌一
排序键前三列哪几列一定出现在 WHERE 前面;有没有主键访问路径要保每个位置都是排他资源:放了标签就挤掉 user_id,点查与游标翻页失去前缀索引一
分桶列与桶数唯一分布均匀的列;tablet 目标大小 1G–10G分桶列放 user_id 服务均衡,圈选时一个桶都裁不掉(tablets=32/32);桶切碎多付 2% 存储一
给哪几列建索引进条件频率 × 单值命中占比两个维度;取值在页间有无偏斜每列一次 BUILD INDEX 约 13 分钟、约 412 MB;无偏斜列上 bloom filter 白付 131 MB二
导入批大小与提交方式同一 tablet 上的并发导入数;版本增速 vs 合并追得上的速度async_mode 省 30 倍版本但延迟 12 秒可见;串行 + sync_mode 是最坏组合三
人群包存成什么形态这批包接下来的典型动作是计数还是交付位图集合运算最多省 1.2 万倍内存,但导出多付 49%、构建不省、重算漏删静默虚高 7 倍四

这张表里唯一跨行通用的判据是「命中占比」那一列。它同时决定了索引值不值得建(第二章:0.2% 的列省 499 倍,35% 的列一分不省)、决定了位图值不值(第四章:密度 5% 时它退化成 2 字节/值的数组容器,压不出东西),也决定了明细表划算不划算(同一个 ID 放进排序键 1.58 字节/行,挪出来 3.56 字节/行)。先看数据的分布,再挑引擎的特性,顺序反过来就是白付。

变慢之后的提问顺序

圈选查询慢下来时,按这个顺序问,每一步都有确定的取证动作,不要从「加索引」开始猜。

第一步:多读了什么。先拿扫描量,别拿耗时——同一台机器上同一条查询冷跑 309 ms、热跑 33 ms,耗时不能当证据。扫描量在 query profile 里,2.1 的取数路径三步(SHOW PROFILELIST 在 2.1.11 直接报语法错,走 HTTP 接口):

SET enable_profile = true;
-- 跑一次那条圈选,然后从 FE 审计日志里拿它的 QueryId
tail -5 /opt/apache-doris/fe/log/fe.audit.log | grep -o 'QueryId=[a-f0-9-]*'
curl -s -u root: "http://127.0.0.1:8030/api/profile?query_id=<QueryId>" \
  | grep -E 'RawRowsRead|RowsRead'

两个数对照着看:RawRowsRead 是从存储层真实读出的行数,RowsRead 是过滤后交给上层的行数。两者的比值就是本章反复出现的那个「放大倍数」,第二章那条 499× 就是这么量出来的。审计日志里那个 ScanRows 别拿来算放大——第二章那条「读 3000 万、留 182.6 万」的组合圈选,审计里 ScanRows 报的是 1,825,511,和 RowsRead 几乎相等。它统计的是过滤后交给上层的行数,不是从存储层真实读出的行数;照它算,任何查询都不存在读放大。

第二步:多算了什么。比值正常(1~3 倍)但耗时高、内存高,问题在算子层。峰值内存看审计日志的 PeakMemoryBytes:第四章的 count(DISTINCT user_id) 求并吃了 1.39 GB,同一结果用位图只要 0.12 MB。OLAP 查询的内存几乎总是「把 N 万个值装进哈希表」这件事,Java 侧的直觉在这里完全适用:先看聚合与连接的基数,再看扫描。

第三步:计划为什么这么选。前两步定位到列或算子之后,用 EXPLAIN 确认引擎是不是真的用上了你以为它在用的能力。同一张表上两种查询的计划尾部,对照着读:

EXPLAIN SELECT count(*) FROM member_profile
WHERE tenant_id = 1 AND city_code = 101;
  PREDICATES: ((tenant_id[#0] = 1) AND (city_code[#2] = 101))
              AND (__DORIS_DELETE_SIGN__[#42] = 0)
  partitions=1/1 (member_profile)
  tablets=32/32, tabletList=14134,14136,14138 ...      -- 一个桶都没裁掉
  cardinality=200000015

EXPLAIN SELECT level FROM member_profile
WHERE tenant_id = 1 AND user_id = 12345678;
  tablets=1/32                                          -- 分桶列进条件了,裁到 1 个桶

PREDICATES 那几行告诉你谓词被推到了扫描节点上(以及主键表必然带的 __DORIS_DELETE_SIGN__ 过滤),partitions 与 tablets 的 已选/总数 告诉你裁剪有没有发生,cardinality 是优化器以为自己扫多少。这三个数没有任何一个能从耗时里看出来,而本章的每一次「明明加了却没生效」都是在这里被抓到的:bloom filter 加了、RawRowsRead 一行没少;倒排索引建了、命中面一放大又回到全读。生效与否只能用扫描量和计划文本证明,不能由「DDL 返回 OK」证明。

写入侧变慢是另一条链,顺序也一样:SHOW TABLETS FROM 表 看每个 tablet 的 Version 与 VersionCount → BE 日志搜 compaction 与该 tablet id,确认合并追不追得上 → 导入返回 JSON 里的时间分解确认卡在哪一段——本机一次真实导入的返回给了这几个字段:BeginTxnTimeMs 41、StreamLoadPutTimeMs 10(FE 建导入计划)、ReceiveDataTimeMs 54、WriteDataTimeMs 26、CommitAndPublishTimeMs 23,总计 LoadTimeMs 103。

本章那个 max_tablet_version_num = 2000 的坑就是在这条链上发现的:阈值判的是未合并的 rowset 数,不是版本号,所以「灌 500 次一定撞 -235」这类推演不成立,别把它写进容量文档。

三件可以直接带走的事

第一,口径先于数字。这篇文章里所有耗时都取 FE 审计日志的 Time(ms)、所有扫描量都取 query profile、所有存储都在 BE 上直接量目录——因为这三类数字在 Doris 里各有两三个看似可用的来源,而它们互相矛盾(SHOW DATA 有分钟级延迟、审计 ScanRows 不可信、shell 墙钟被建连开销吃掉 40 ms)。没有固定口径,前后两次测量就没有可比性,调优会变成随机漫步。

第二,任何「加了 X 就会更快」的判断,都要在同一份数据上跑一遍对照。本文写到这里,被实测推翻的直觉有四个:位图省存储(不省,明细放排序键更省)、位图让圈选更快(构建慢 5%~40%,省的是集合运算的内存)、bloom filter 适用于高基数等值列(无偏斜时一行都跳不掉)、桶切得多并行更好(tablet 碎到 248 MB,官方建议区间是 1G–10G)。四个都来自「合理但错」的通用经验,共同点是它们跳过了数据的实际分布。

第三,形态选择比参数调整更值钱。这台机器上没有一个数字是靠调 BE/FE 参数得到的:换模型省了全量重灌、换排序键位置换来了游标翻页、换索引形态把 499 倍降到 1 倍、换人群包形态把 1.39 GB 降到 0.12 MB。参数当然有用,但圈选场景里最大的一笔收益始终在「这一份数据以什么形状落在磁盘上」——这也正是上一章那些机制(有序、版本、列编码、分桶)在使用侧的兑付方式。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。