[航空] 旅客航司权限控制:两种方法对比与最佳实践

航空旅客数据权限控制方案对比,两种行级别安全实现方法的优劣分析与最佳实践。

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 核心问题

  1. 如何标记旅客飞过哪些航司——一个旅客对应多个航司,多值关系如何存储
  2. 如何根据用户身份过滤数据——Tableau 怎么识别「我是 HU 员工」并只返回 HU 旅客
  3. 如何在亿级数据下保证性能——过滤延迟必须控制在亚秒级

二、模拟场景:表结构与数据

为了直观说明两种方法的差异,本章模拟一组简化的表结构和数据。这些表不需要额外创建——它们是航空业务系统中天然存在的基础数据。

本章仅用于说明权限控制的逻辑,不涉及数仓建模或 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 毫秒

用代码说明差异:

CONTAINS vs ARRAY_CONTAINS 核心差异

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 中如何传递用户参数?

  1. 创建计算字段 [UserAirline] = LEFT(USERNAME(), 2)
  2. 在自定义 SQL 中使用参数占位符 :UserAirline
  3. Tableau 执行时自动将参数值注入 SQL
  4. 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