by XILEJUN | 20260430 V1.1 更新:增加 4 张流程图,优化章节结构
场景:航空常旅客分析系统,不同航司员工登录后只能查看自己航司的旅客数据 数据量级:旅客千万至亿行,机票明细数亿至数十亿行 技术栈:Apache Doris 1.2+ / Tableau 2024+
一、需求背景
航空公司的常旅客分析系统中,一个核心的安全需求是行级数据权限控制:海航(HU)员工登录后只能看到飞过 HU 的旅客,国航(CA)员工只能看到飞过 CA 的旅客。这个需求的难点在于——大多数旅客不止飞过一个航司,旅客 A 可能同时是 HU 和 JD 的客户。
本章梳理业务场景和核心问题,后续章节分别介绍两种实现方法并做对比。
1.1 业务场景
假设某航空联盟的数据平台,对接了 HU、CA、MU、JD、9C 五家航司的旅客数据。每家航司有各自的业务分析人员,登录 Tableau 仪表板后,只能查看与自己航司相关的旅客信息。
典型用户视角:
用户 | 所属航司 | 能看到的旅客
- 用户: 张三, 所属航司: HU, 能看到的旅客: 飞过 HU 的旅客(含同时飞过 CA 的)
- 用户: 李四, 所属航司: CA, 能看到的旅客: 飞过 CA 的旅客(含同时飞过 HU 的)
- 用户: 王五, 所属航司: 管理员, 能看到的旅客: 全部旅客
1.2 核心问题
- 如何标记旅客飞过哪些航司——一个旅客对应多个航司,多值关系如何存储
- 如何根据用户身份过滤数据——Tableau 怎么识别「我是 HU 员工」并只返回 HU 旅客
- 如何在亿级数据下保证性能——过滤延迟必须控制在亚秒级
二、模拟场景:表结构与数据
为了直观说明两种方法的差异,本章模拟一组简化的表结构和数据。这些表不需要额外创建——它们是航空业务系统中天然存在的基础数据。
本章仅用于说明权限控制的逻辑,不涉及数仓建模或 ETL 设计。
2.1 基础表:旅客信息表
dim_passenger 存储旅客基本信息,每行一个旅客:
passenger_id | name | phone | level
- passenger_id: P001, name: 王五, phone: 138**1001, level: GOLD
- passenger_id: P002, name: 赵六, phone: 139**1002, level: SILVER
- passenger_id: P003, name: 钱七, phone: 137**1003, level: PLATINUM
- passenger_id: P004, name: 孙八, phone: 136**1004, level: NORMAL
2.2 基础表:机票明细表
ods_ticket 存储每张机票的记录,每行一张票,天然包含旅客与航司的关系:
ticket_id | passenger_id | airline_code | flight_date | route
- ticket_id: T001, passenger_id: P001, airline_code: HU, flight_date: 2024-01-15, route: PEK-CAN
- ticket_id: T002, passenger_id: P001, airline_code: JD, flight_date: 2024-03-20, route: PEK-NNG
- ticket_id: T003, passenger_id: P002, airline_code: HU, flight_date: 2024-02-10, route: SHA-PEK
- ticket_id: T004, passenger_id: P002, airline_code: CA, flight_date: 2024-04-05, route: SHA-CTU
- ticket_id: T005, passenger_id: P002, airline_code: MU, flight_date: 2024-06-18, route: SHA-PVG
- ticket_id: T006, passenger_id: P003, airline_code: HU, flight_date: 2024-01-01, route: CAN-SHA
- ticket_id: T007, passenger_id: P004, airline_code: 9C, flight_date: 2024-05-01, route: PVG-KMG
2.3 从明细中提取「旅客-航司」关系
旅客飞过哪些航司,答案已经在 ods_ticket 表中了,不需要额外建表存储。只需要按旅客聚合即可:
P001 → HU, JD (飞过 2 个航司)
P002 → HU, CA, MU (飞过 3 个航司)
P003 → HU (只飞过 1 个航司)
P004 → 9C (只飞过 1 个航司)
关键问题转化为:如何高效地聚合和存储这组多值关系,并在查询时快速过滤?
接下来的两章分别介绍两种方法——字符串拼接和 ARRAY 类型。
三、方法一:字符串拼接 + Tableau CONTAINS
这是一种最容易想到、实现最简单的方法:用 SQL 聚合函数把一个旅客的多个航司拼成一个字符串,然后在 Tableau 中用 CONTAINS 做子串匹配。
它最大的优点是入门门槛低、任何版本的 Doris 和 Tableau 都能用。但在亿级数据场景下,性能问题会非常突出。本章先介绍完整实现,再分析其局限性。
3.1 实现步骤
第一步:聚合旅客航司为字符串
使用 Doris 的 GROUP_CONCAT 函数,将机票明细中每个旅客的航司代码拼接为一个逗号分隔的字符串:
[sql] -- 查询结果:每个旅客一行,airlines 为逗号分隔的字符串
SELECT
t.passenger_id,
p.name,
p.level,
GROUP_CONCAT(DISTINCT t.airline_code) AS airlines
FROM ods_ticket t
JOIN dim_passenger p ON t.passenger_id = p.passenger_id
GROUP BY t.passenger_id, p.name, p.level
ORDER BY t.passenger_id;
查询结果:
passenger_id | name | airlines
- passenger_id: P001, name: 王五, airlines: HU,JD
- passenger_id: P002, name: 赵六, airlines: HU,CA,MU
- passenger_id: P003, name: 钱七, airlines: HU
- passenger_id: P004, name: 孙八, airlines: 9C
也可以将结果物化为一张视图或宽表,供 Tableau 直连查询。
第二步:Tableau 端过滤
在 Tableau 中创建两个计算字段:
// 计算字段 1:提取当前用户的航司代码
[UserAirline] = LEFT(USERNAME(), 2)
// 假设用户名格式为 HU_zhangsan
// 计算字段 2:判断旅客是否属于当前航司
[Airline Filter] = CONTAINS([airlines], [UserAirline])
将 [Airline Filter] 拖入筛选器,勾选 True,即完成行级权限控制。
3.2 效果演示
HU 员工(张三)登录 Tableau 后,执行流程如下:
1. Tableau 提取用户名 HU_zhangsan → [UserAirline] = 'HU'
2. 对每行旅客数据执行 CONTAINS(airlines, 'HU')
3. P001: CONTAINS('HU,JD', 'HU') → True ✅ 显示
4. P002: CONTAINS('HU,CA,MU', 'HU') → True ✅ 显示
5. P003: CONTAINS('HU', 'HU') → True ✅ 显示
6. P004: CONTAINS('9C', 'HU') → False ❌ 过滤
最终张三只能看到 P001、P002、P003 三个旅客。
3.3 方法的局限性
这个方法看似完美,但在实际生产中存在三个严重问题:
问题一:CONTAINS 是子串匹配,会误报
CONTAINS 判断的是「字符串 A 中是否包含字符串 B」,而非「数组中是否存在某个元素」:
假设某航司代码为 'HUH'(虽然目前不存在,但代码规则可能变化):
CONTAINS('HUH', 'HU') → True ❌ 误报!
CONTAINS('CHU', 'HU') → True ❌ 误报!
正确结果应该是 False——这两个旅客并没有飞过 HU。
虽然当前 IATA 航司代码都是两位字母,但这种隐含的匹配规则是脆弱的——一旦代码规则变化或出现前缀包含的情况,就会产生数据泄露。
问题二:过滤在 Tableau 端执行,无法下推
这是最致命的问题。Tableau 的 CONTAINS 是在 Tableau Server 的内存中执行的,而不是下推到 Doris:
执行流程如下:
执行流程:
Doris 执行 SQL → 返回全部旅客数据(1 亿行)
↓ 网络传输 1 亿行数据到 Tableau Server
Tableau 内存中逐行执行 CONTAINS → 过滤出 HU 旅客
↓ 渲染仪表板
环节 | 耗时估算
- Doris 查询全部数据 — 1-3 秒
- 网络传输 1 亿行 — 5-15 秒
- Tableau 内存加载 + CONTAINS 过滤 — 10-30 秒
- 总计 — 15-50 秒
而通过自定义 SQL + 参数下推的方式,Doris 端只返回过滤后的数据,延迟可降到亚秒级。
问题三:字符串的更新与维护成本高
GROUP_CONCAT 生成的字符串难以精确维护:
-- 想给 P001 新增一个航司 MU:
-- 字符串方案需要先读取旧值,拼接新值,再写回
UPDATE ads_passenger
SET airlines = CONCAT(airlines, ',MU')
WHERE passenger_id = 'P001';
-- 结果:'HU,JD' → 'HU,JD,MU'
-- 想删除一个航司 JD:
-- 需要字符串替换,容易出错
UPDATE ads_passenger
SET airlines = REPLACE(airlines, 'JD,', '') -- 如果 JD 在末尾呢?
WHERE passenger_id = 'P001';
每个航司代码在字符串中只是子串,没有独立的结构,增删操作都需要复杂的字符串处理。
3.4 小结
评估维度 | 字符串拼接方案
- 实现难度 — ⭐ 低,任何版本都可用
- 匹配精确性 — ❌ 子串匹配,存在误报风险
- 过滤下推 — ❌ CONTAINS 在 Tableau 端执行
- 亿级数据延迟 — ❌ 15-50 秒,用户体验差
- 数据库索引支持 — ❌ 字符串字段无法针对单个航司建索引
- 更新维护 — ❌ 字符串拼接/替换,容易出错
字符串拼接方案适合数据量百万级以下的轻量场景。亿级数据下,它不是正确答案。下一章介绍 ARRAY 方案如何从根本上解决这些问题。
四、方法二:ARRAY 类型 + ARRAY_CONTAINS(推荐)
ARRAY 是 Doris 1.2+ 原生支持的数据结构,允许在一个字段中存储一组独立的、有类型的元素。与方法一的字符串拼接不同,ARRAY 中的每个航司代码是一个独立的值,数据库可以对它建索引、做精确匹配。
配合 Tableau 的自定义 SQL 参数下推,过滤条件在 Doris 端执行,亿级数据下仍能保持毫秒级响应。
4.1 实现步骤
第一步:聚合旅客航司为 ARRAY
使用 Doris 的 COLLECT_LIST(或 COLLECT_SET)函数,将航司代码聚合为数组:
[sql] SELECT
t.passenger_id,
p.name,
p.level,
COLLECT_SET(t.airline_code) AS airlines -- 返回 ARRAY 类型
FROM ods_ticket t
JOIN dim_passenger p ON t.passenger_id = p.passenger_id
GROUP BY t.passenger_id, p.name, p.level
ORDER BY t.passenger_id;
查询结果:
passenger_id | name | airlines
- passenger_id: P001, name: 王五, airlines: ['HU', 'JD']
- passenger_id: P002, name: 赵六, airlines: ['HU', 'CA', 'MU']
- passenger_id: P003, name: 钱七, airlines: ['HU']
- passenger_id: P004, name: 孙八, airlines: ['9C']
与方法一的字符串 'HU,JD' 不同,ARRAY ['HU', 'JD'] 中的每个元素是独立的值,有自己的类型和边界。
第二步:Tableau 自定义 SQL(参数下推到 Doris)
在 Tableau 数据源中使用自定义 SQL,将航司过滤条件嵌入 SQL:
[sql] SELECT
t.passenger_id,
p.name,
p.level,
COLLECT_SET(t.airline_code) AS airlines,
SIZE(COLLECT_SET(t.airline_code)) AS airline_cnt
FROM ods_ticket t
JOIN dim_passenger p ON t.passenger_id = p.passenger_id
GROUP BY t.passenger_id, p.name, p.level
-- ⭐ 关键:过滤条件在这里执行,在 Doris 端
HAVING ARRAY_CONTAINS(COLLECT_SET(t.airline_code), :UserAirline)
也可以将聚合结果物化为宽表,查询更简洁:
[sql] -- 如果已将 ARRAY 物化为 ads_passenger 表
SELECT
passenger_id,
name,
level,
airlines,
flight_cnt,
airline_cnt
FROM ads_passenger
WHERE ARRAY_CONTAINS(airlines, :UserAirline)
ORDER BY flight_cnt DESC
LIMIT 10000;
第三步:Tableau 参数绑定
// 计算字段:提取当前用户航司代码
[UserAirline] = LEFT(USERNAME(), 2)
参数 :UserAirline 在 SQL 执行前被 Tableau 替换为实际值,整个过滤过程在 Doris 端完成。
4.2 两种方法的执行流程对比
同样的需求——HU 员工查看 HU 旅客——两种方法的执行路径完全不同:
方法一(字符串拼接)的执行路径
方法二(ARRAY + 参数下推)的执行路径
4.3 为什么 ARRAY_CONTAINS 优于 CONTAINS
这两个函数表面功能相似——「判断是否包含某个值」,但底层机制完全不同:
对比维度 | CONTAINS (Tableau) | ARRAY_CONTAINS (Doris)
- 对比维度: 执行位置, CONTAINS (Tableau): Tableau Server 内存, ARRAY_CONTAINS (Doris): Doris 数据库引擎
- 对比维度: 输入类型, CONTAINS (Tableau): 字符串 → 字符串, ARRAY_CONTAINS (Doris): ARRAY → 标量值
- 对比维度: 匹配机制, CONTAINS (Tableau): 子串扫描(pattern matching), ARRAY_CONTAINS (Doris): 逐元素精确比较(element == value)
- 对比维度: 精确性, CONTAINS (Tableau): ❌ 'HUH' 会匹配 'HU', ARRAY_CONTAINS (Doris): ✅ 'HUH' ≠ 'HU',精确无误报
- 对比维度: 索引支持, CONTAINS (Tableau): ❌ 无法利用任何数据库索引, ARRAY_CONTAINS (Doris): ✅ Bloom Filter + 倒排索引
- 对比维度: 数据传输, CONTAINS (Tableau): 全量 1 亿行传输到 Tableau, ARRAY_CONTAINS (Doris): 仅返回匹配行(约 1/N)
- 对比维度: 亿级延迟, CONTAINS (Tableau): 15-50 秒, ARRAY_CONTAINS (Doris): 100-500 毫秒
用代码说明差异:
4.4 ARRAY 的额外优势
除了精确匹配和过滤下推,ARRAY 类型还有几个实用优势:
优势一:数据库可建索引加速
[sql] -- 建表时启用 Bloom Filter
CREATE TABLE ads_passenger (
...
airlines ARRAY<VARCHAR(10)>,
...
)
PROPERTIES ("bloom_filter_columns" = "airlines");
-- Doris 2.0+ 可建倒排索引
ALTER TABLE ads_passenger ADD INVERTED INDEX idx_airlines(airlines);
Bloom Filter 和倒排索引都是针对 ARRAY 内独立元素的,可以快速判断「哪些行包含 HU」,大幅减少扫描范围。字符串拼接的字段无法利用这些索引。
优势二:丰富的数组函数
[sql] -- 旅客飞过几个航司
SELECT SIZE(airlines) AS airline_cnt FROM ads_passenger;
-- 数组转字符串(兼容需要字符串的场景)
SELECT ARRAY_JOIN(airlines, ',') FROM ads_passenger;
-- 判断是否同时飞过 HU 和 CA
SELECT * FROM ads_passenger
WHERE ARRAY_CONTAINS(airlines, 'HU')
AND ARRAY_CONTAINS(airlines, 'CA');
-- 两个旅客是否有共同航司
SELECT ARRAY_INTERSECT(a.airlines, b.airlines) FROM ...;
优势三:元素独立,更新语义清晰
[sql] -- 新增一个航司(整行替换,语义明确)
UPDATE ads_passenger
SET airlines = ARRAY_APPEND(airlines, 'MU')
WHERE passenger_id = 'P001';
-- 结果:['HU', 'JD'] → ['HU', 'JD', 'MU']
-- 无需担心字符串拼接的边界问题(逗号位置、前缀包含等)
4.5 小结
评估维度 | 字符串拼接(方法一) | ARRAY 类型(方法二)
- 评估维度: 实现难度, 字符串拼接(方法一): ⭐ 低, ARRAY 类型(方法二): ⭐⭐ 中
- 评估维度: 匹配精确性, 字符串拼接(方法一): ❌ 子串匹配,有误报, ARRAY 类型(方法二): ✅ 元素精确匹配
- 评估维度: 过滤下推, 字符串拼接(方法一): ❌ Tableau 端执行, ARRAY 类型(方法二): ✅ Doris 端执行
- 评估维度: 数据库索引, 字符串拼接(方法一): ❌ 不可用, ARRAY 类型(方法二): ✅ Bloom Filter + 倒排索引
- 评估维度: 亿级数据延迟, 字符串拼接(方法一): 15-50 秒, ARRAY 类型(方法二): 100-500 毫秒
- 评估维度: 数据传输量, 字符串拼接(方法一): 全量 1 亿行, ARRAY 类型(方法二): 过滤后约 1/N
- 评估维度: 更新维护, 字符串拼接(方法一): 字符串处理易出错, ARRAY 类型(方法二): 元素独立,语义清晰
- 评估维度: 版本要求, 字符串拼接(方法一): 任意版本, ARRAY 类型(方法二): Doris 1.2+
五、性能基准测试
理论分析之外,本节提供一组在亿级数据下的性能对比数据,量化两种方法在真实场景中的差距。
测试环境:Doris 3 节点集群,12 BE cores/节点,旅客表 1 亿行
5.1 查询性能对比
查询场景 | 字符串拼接 + CONTAINS | ARRAY + ARRAY_CONTAINS | 提升倍数
- 查询场景: 单航司过滤(返回 100 万行), 字符串拼接 + CONTAINS: 23.5 秒, ARRAY + ARRAY_CONTAINS: 0.4 秒, 提升倍数: 59x
- 查询场景: 单航司过滤(返回 10 万行), 字符串拼接 + CONTAINS: 18.2 秒, ARRAY + ARRAY_CONTAINS: 0.2 秒, 提升倍数: 91x
- 查询场景: 两航司交叉过滤, 字符串拼接 + CONTAINS: 35.7 秒, ARRAY + ARRAY_CONTAINS: 0.6 秒, 提升倍数: 60x
- 查询场景: 聚合统计(COUNT/GROUP BY), 字符串拼接 + CONTAINS: 28.3 秒, ARRAY + ARRAY_CONTAINS: 0.3 秒, 提升倍数: 94x
性能差距的主要来源:字符串方案的网络传输(1 亿行全量回传)占总耗时的 60-70%。
5.2 数据传输量对比
指标 | 字符串拼接方案 | ARRAY + 参数下推方案
- 指标: Doris 扫描行数, 字符串拼接方案: 1 亿行, ARRAY + 参数下推方案: 1 亿行(但 Bloom Filter 减少实际 IO)
- 指标: 网络传输行数, 字符串拼接方案: 1 亿行, ARRAY + 参数下推方案: 约 2000 万行(1/5 航司)
- 指标: 网络传输数据量, 字符串拼接方案: 约 15 GB, ARRAY + 参数下推方案: 约 3 GB
- 指标: Tableau 内存加载, 字符串拼接方案: 15 GB, ARRAY + 参数下推方案: 3 GB
5.3 结论
在亿级数据场景下,ARRAY + 参数下推方案的性能优势是数量级的。字符串拼接方案唯一的优势是「简单」,但当数据规模超过千万行时,这种简单就变成了用户体验的灾难。
六、常见问题
Q1:Doris 版本低于 1.2,不支持 ARRAY 怎么办?
使用 VARCHAR 存储逗号分隔字符串 + Doris 端 LIKE 过滤(注意:仍然是子串匹配,有误报风险):
[sql] -- 退而求其次:在自定义 SQL 中用 LIKE 替代 CONTAINS,至少实现下推
SELECT * FROM ads_passenger_str
WHERE airlines_str LIKE '%HU%'
AND airlines_str NOT LIKE '%HUH%' -- 手动排除误报
但强烈建议升级到 Doris 1.2+ 以使用原生 ARRAY。
Q2:GROUP_CONCAT 和 COLLECT_SET 有什么区别?
函数 | 返回类型 | 去重 | 分隔符
- 函数: GROUP_CONCAT(expr), 返回类型: VARCHAR, 去重: 支持 DISTINCT, 分隔符: 默认逗号
- 函数: COLLECT_SET(expr), 返回类型: ARRAY, 去重: 自动去重, 分隔符: 无(数组元素独立)
- 函数: COLLECT_LIST(expr), 返回类型: ARRAY, 去重: 不去重, 分隔符: 无
推荐使用 COLLECT_SET,自动去重且返回 ARRAY 类型。
Q3:ARRAY 字段会不会太大?
航空场景下,一个旅客通常飞过 3-10 个航司,极端情况不超过 50 个。每个航司代码 2 字节,50 个也才 100 字节,完全可控。
Q4:Tableau 的自定义 SQL 中如何传递用户参数?
- 创建计算字段 [UserAirline] = LEFT(USERNAME(), 2)
- 在自定义 SQL 中使用参数占位符 :UserAirline
- Tableau 执行时自动将参数值注入 SQL
- Doris 收到的已经是完整的 SQL(WHERE 条件已确定),直接执行
Q5:为什么方法一中不能把 CONTAINS 也下推到 Doris?
可以。你可以在自定义 SQL 中写 WHERE airlines_str LIKE '%HU%',实现条件下推。但问题仍然存在:
- LIKE 是子串匹配,与 CONTAINS 一样有误报风险
- VARCHAR 字符串字段无法利用 Bloom Filter 或倒排索引
- 性能比 ARRAY_CONTAINS 仍然差很多(但没有 Tableau 端过滤那么夸张)
如果你的 Doris 版本不支持 ARRAY,这是退而求其次的选择。
文档版本:V1.1 | 2026-04-30 适用场景:航空常旅客系统行级权限控制,亿级数据量
📖 相关文章
● 中小企业 BI最佳实践:阿里/腾讯云服务器+Tableau DW/BI 一体化方案
● 基于RFM模型与Tableau Prep NTILE函数的旅客价值分层实现
● 业务对象及其到数据的映射:不要把「航班号」拆成两半
● 【致知篇44】逻辑世界:数据、佛法与体系
● 8.1 计算的演进及分类:从Excel、SQL到Tableau
——————————————————————————————
No comments yet