医院数据平台团队干了三年多,我最怕的不是数据库容量告警,而是临床科室下午三点准时打进电话来:“昨天的病区统计报表怎么还没出?”一开始我们用的是当年的老办法:业务库直接查,或者每天凌晨跑一批Spark任务。可等数据量爬到几十亿行,老办法彻底顶不住了——夜间任务延迟到早上七八点还在跑,早上开交班会之前,护士长已经截了三次屏在群里问数据。后来我们引入了Apache Doris作为临床数据分析的核心引擎,把这套链路彻底重做了一遍。
这篇博文就是我当时从选型、部署、建模、权限到慢查询调优的完整复盘。目标人群很明确:正在做医疗信息化、临床科研、健康管理平台的数据工程师或架构师,也适合那些刚接手“医院数据平台建设”但还没找到头绪的团队。我会把踩过的坑、验证过的配置、可以直接抄的建表和权限方案都写出来。如果你正准备搭第一个Doris测试集群,或者已经被医疗数据的性能问题折磨了一周,这篇文章应该能帮你省下一两个月的试错时间。
1. 临床数据分析跑不动了:医疗数据规模与延迟的双重压迫
1.1 医疗数据的三个“不友好”特征
很多没做过医疗数据的同学,一开始会觉得医疗数据不就是表多一点、量级大一点嘛。真正做进去才发现,医疗数据在分析场景里有三个非常不友好的特征。
第一个是量级大。三甲医院十几年历史数据,诊断记录、检验报告、医嘱、体征、微生物、手术麻醉、随访记录,事实表动辄几十亿行。单看一张表可能不算夸张,但这些事实表之间要互相join,比如把检验结果和患者就诊记录关联起来看趋势,数据膨胀得非常快。
第二个是结构乱。同一个指标在不同科室、不同系统里的口径不一样。以血糖为例,LIS里存的是“静脉血糖”,护理记录里是“指尖血糖”,有的系统还分“空腹”“餐后”“睡前”,格式和单位五花八门。分析之前得先做一轮清洗和标准化。
第三个是查法野。业务方不是拿着固定几张报表过日子,今天按病种、明天按时间段、后天按医生,查询条件永远跟着问题走。传统的报表系统根本跟不上这种需求变化,而临床医生也不会关心你的数据仓库怎么建模,他们只会说“把这个条件帮我加一下,明天要”。
1.2 老架构的瓶颈到底卡在哪
我们当时的架构其实很常见:Oracle和MySQL扛业务系统,另有一套Hadoop环境跑离线批量任务。
业务库的问题最直接:它是为了在线交易设计的,你把一个大聚合查询丢上去,数据库的CPU瞬间打满,慢查询日志刷屏,连带着门诊挂号系统的响应都变慢。所以后来我们定了铁规矩,超过一百万行的分析查询绝对不允许直接打业务库。
Hadoop这边也有问题。Spark任务确实能跑大数据量,但调度、排队、YARN资源分配都是麻烦事。我的真实体感是:一个医疗数据团队如果只有三五个人,维护一套Hadoop全家桶的精力会占到六成,真正做数据分析的时间只剩四成。而且批处理延迟摆在那里,今天跑昨天的数据,遇到数据回刷和口径修正,表重跑一次又得等半天。
1.3 我们要的其实是“秒级交互”
后来我慢慢想明白一件事:临床数据分析的场景里,业务方要的不只是“能出数”,更是“能快点出数”。医生站在病床前看血糖趋势,护士长早上查房前要看病区统计,医务科开会临时要一个抗菌药物使用率排名——这些场景的等待耐心只有几秒到几十秒。一旦查询能做到秒级返回,产品的形态就完全不一样了,医生可以自己去拖维度、选条件、看趋势,而不是提需求等排期。
这也是我们最终决定引入Doris的核心出发点:它要能扛住几十亿行数据的存储和计算,同时把交互式查询延迟做到秒级。性能不是锦上添花,而是这项技术能不能在医疗场景里活下来的生命线。
2. Doris凭什么切入医疗场景:与ClickHouse、Presto的选型对比
2.1 选型不是“谁快选谁”,而是“谁适配你的查询模式”
医疗分析场景和互联网日志分析有个很大区别:日志分析大多是单张宽表的高吞吐扫描,而医疗分析除了大聚合,还天然带着大量多表Join、患者级别明细追踪和权限控制。
举一个我们当时反复对比的例子:查询“某科室近半年血糖控制率”,ClickHouse的单表聚合非常强,但要把就诊记录、科室字典、检验结果几张表关联起来,SQL写起来就容易绕,而且并发一高,ClickHouse对复杂Join的资源隔离并不算友好。Presto则可以做到联邦查询,但它本身不存储数据,每次查询都从底层拉数据,持续跑大数据量的稳定性需要额外关注。
2.2 三个引擎的横向对比
我整理了一张选型对比表,当时就是拿着这张表去找科室主任和各路业务方聊需求的:
| 对比维度 | Doris | ClickHouse | Presto/Trino |
|---|---|---|---|
| 架构 | MPP列存,FE负责元数据和协调,BE负责存储计算 | 列存分布式,无中心节点,各节点对等 | 无状态SQL引擎,底层依赖外部存储 |
| SQL兼容 | 兼容MySQL协议,标准SQL,上手门槛低 | 有自己的SQL方言,部分语法不通用 | ANSI SQL较完整,适合联邦查询 |
| 数据更新 | Unique模型支持主键更新,Aggregate支持预聚合 | 更新较弱,主要靠插入重算或倒排 | 只读查询,不支持存储和更新 |
| Join能力 | 较好,适合多表关联,配合Runtime Filter优化 | 弱项,大表Join容易内存爆掉 | 取决于底层数据源和连接器实现 |
| 高并发 | 较好,FE可以横向扩展,支持多租户 | 一般,复杂查询容易打满单节点 | 一般,适合临时分析场景 |
| 运维复杂度 | 中等,FE/BE两个角色 | 中等,副本配置和节点运维也要上心 | 低,但要维护外部存储集群 |
| 典型场景 | 统一数仓明细层+分析层,BI报表+交互查询 | 日志分析、指标聚合、宽表大扫描 | 跨数据源临时查询、数据湖分析 |
这不是说ClickHouse不好,它在单表聚合和日志分析场景确实是一把好手。但医疗临床分析的常态是“多变查询+明细挖掘+权限控制”,Doris的模型更贴合这种混合负载。
2.3 我们最终选Doris的几个决定性因素
首先是统一口径。Doris可以作为统一的物理分析库,视图、权限、SQL门禁都集中管理,不用再拼凑两套引擎。其次是查询模式匹配,Doris的物化视图和Rollup能把常用聚合预先算好,明细查询又能直接走DUPLICATE模型,一套引擎覆盖两种需求。运维上也更可控,FE和BE两类节点,相比全家桶要轻得多。再加上Doris兼容MySQL协议,医院现有的BI工具、研发团队的Java/Go代码都能直接连,学习成本一下子降下来了。
3. 集群部署与基础配置:那些文档里不会写的坑
3.1 拓扑规划:生产环境别省FE的副本数
Doris的架构其实不复杂:FE(Frontend)负责元数据管理、查询解析和协调,BE(Backend)负责数据存储和计算。生产环境我建议至少3个FE,一个Master加两个Follower,元数据用多数派机制保证不丢。如果BI并发查询量大,可以再加Observer节点分担查询压力,Observer不参与元数据选举,专职读。
BE节点的数量主要看数据量和磁盘容量,一般3台起步。副本数建议设为3,虽然会多占一些磁盘,但医疗数据不能丢,节点宕掉一个还能保证线上查询不中断。测试环境当然可以一FE一BE,省资源,但别把测试环境的配置直接搬到生产。
3.2 安装步骤:按这个顺序能少折腾半天
这里给出我们验证过的安装流程:
- 下载Apache Doris的二进制发行版,建议JDK 17,老版本配JDK 8/11也能跑,但新版本特性会有要求。
- 解压到数据目录,分别进入fe和be目录。
- 修改fe/conf/fe.conf,重点是priority_networks,必须明确指定内网网段,否则多网卡机器上FE之间通信会抓错网卡。
- 修改be/conf/be.conf,重点是storage_root_path配置,多个磁盘目录用分号分隔,例如
/data1/doris_storage;/data2/doris_storage,别用逗号,逗号会被解析成单目录下的属性分隔符。 - 先启动FE,再启动BE。用MySQL客户端连FE的9030端口,执行
ALTER SYSTEM ADD BACKEND "be_host:9050"把BE注册进来。 - 执行
SHOW BACKENDS; SHOW FRONTENDS;确认节点状态都是ALIVE。
有一个细节非常容易忽略:BE启动之前必须确保操作系统的文件句柄数足够大,建议把ulimit -n调到至少65536。否则数据量上来之后,BE进程会莫名其妙报“Too many open files”,但你从日志上很难一眼看出来是这个原因。
3.3 部署期最容易踩的五个坑
坑一:swap没关。操作系统一旦开始swap,Doris的查询延迟会瞬间恶化到不可接受。部署时就把swap关闭,或者用sysctl vm.swappiness=1把swap倾向调到最低。
坑二:priority_networks没配。症状是FE注册BE后,通过SHOW BACKENDS看到的Host是内网IP,但节点状态一直显示不通。排查到最后往往是FE和BE之间走错网卡,两边优先级判断不一致。这个参数建议在FE和BE上都显式配置。
坑三:JVM参数乱调。FE的JVM内存默认值对大多数场景够用,但有些同学喜欢按网上搜来的经验把JAVA_OPTS里的-Xmx调得特别大,反而导致GC停顿。FE内存要给元数据留余量,但过大的堆不一定提升查询性能,元数据操作大多是轻量级的。
坑四:端口没开全。客户端连接用的是9030(MySQL协议),数据导入和BE通信分别涉及8040、9060、9050,如果只把9030放进防火墙白名单,后续Stream Load导入会频繁报连接失败,排查半天才发现是端口被安全组挡了。
坑五:版本选择太激进。Apache Doris的某个新版本可能有特性很吸引你,但如果不是生产验证过的稳定版本,遇到问题连社区issue都少。医疗项目求稳为先,别拿生产环境当新版本试验场。
4. 表模型、分区分桶与数据导入:临床数据建模的关键决策
4.1 表模型怎么选:一个真实的取舍过程
Doris有三种表模型:Aggregate、Unique、Duplicate。很多新手上手就发懵,其实它们的区别一句话就能说清楚:Aggregate适合“多行聚合成一行”的指标场景,Unique适合“主键相同就覆盖更新”的场景,Duplicate就是原始明细原样保存。
医疗临床分析里,我的建议是先把明细表建成Duplicate模型。为什么?因为医生和科研人员随时可能下钻到某一次检验、某一条医嘱,预聚合后的数据没法回答这些细节问题。等跑了一段时间,发现某些高频聚合查询非常固定,再针对性地做物化视图或Rollup。
Unique模型也有用武之地,比如患者主数据、科室信息这类经常需要修正的维度表,用Unique模型保证主键更新。当时我们维护了一张患者标签表,每天从业务系统同步,标签值会变,用Unique模型后,每天的更新任务就简单多了。
4.2 分区与分桶:把查询裁剪到最小集合
临床数据几乎天然按时间查询,“近一周”“近三个月”“今年”是最高频的过滤条件,所以日期字段必须作为分区键。Doris支持动态分区,可以配置自动创建未来N天或者过去N天的分区,避免了手动建分区的重复劳动。
分桶键的选择要更讲究。我见过不少建表时随便选一个列做分桶,结果查询慢得离谱的例子。分桶键应该选择查询条件里高频出现的、区分度高的字段,患者ID、就诊ID、病历号都是不错的选择。分桶数量不是越大越好,每个桶的数据量控制在几百MB到几GB之间比较理想。
这里要特别注意数据倾斜问题。曾经有一个分区下,某个科室的数据量是其他科室的几十倍,如果用科室ID直接分桶,会出现某几个桶超大、其他桶很小的局面,查询性能会退化到和没分桶一样。把分桶键选成患者ID或就诊ID这种高基数字段,能极大降低倾斜概率。
4.3 导入链路:Stream Load、Broker Load、Routine Load的分工
Doris的导入方式很多,我们实际用到的主要有三种:
- Stream Load:一次性的批量导入,通过HTTP接口推数据,适合调度平台触发导入任务时使用。每条导入任务都要有一个label,Doris用label做幂等控制,同一个label重复执行不会产生重复数据。
- Broker Load:适合从对象存储或者HDFS拉数据,批量大、数个小时级别的任务没问题。我们每天凌晨把HIS导出的增量文件放到对象存储,再用Broker Load定时拉进Doris。
- Routine Load:适合持续消费Kafka数据流。医院里的设备数据,比如监护仪、血糖仪、呼吸机,数据量不大但实时性要求高,用Routine Load直接从Kafka写到Doris,延迟控制在分钟级。
导入超时是另一个容易踩的坑。Stream Load的默认超时时间如果预估不够,大文件导入到一半会被切断。实操中我习惯按数据量反推,比如一个10GB的文件,Stream Load的timeout参数会设到600秒以上,宁可多留余量也别让任务频繁失败重跑。
4.4 一个可以直接抄的建表示例
这是当时“血糖监测明细表”的建表语句,用到了Duplicate模型、按日期分区、按就诊ID哈希分桶,注释保留得比较全,方便大家对着改:
CREATE TABLE IF NOT EXISTS dwd_glucose_detail ( patient_id VARCHAR(64) COMMENT '患者ID', visit_id VARCHAR(64) COMMENT '就诊ID', dept_id INT COMMENT '科室ID', ward_id INT COMMENT '病区ID', measure_time DATETIME COMMENT '测量时间', glucose_value DECIMAL(10,2) COMMENT '血糖值', meal_flag TINYINT COMMENT '1空腹 2餐后' ) ENGINE = OLAP DUPLICATE KEY(patient_id, visit_id) PARTITION BY RANGE(measure_time) ( PARTITION p202401 VALUES LESS THAN ('2024-02-01'), PARTITION p202402 VALUES LESS THAN ('2024-03-01') ) DISTRIBUTED BY HASH(visit_id) BUCKETS 32 PROPERTIES ( "replication_num" = "3", "storage_medium" = "SSD" );注意排序键就是DUPLICATE KEY里那两列,Doris默认会把它们作为前缀索引。所以过滤条件里最高频的列最好放在最前面。如果业务上经常按患者ID查,就把patient_id放第一位;如果经常按时间范围查,考虑把measure_time位置提前或者加一个合适的索引列。
5. 医疗数据的行、列权限设计:安全合规的第一道防线
5.1 为什么普通大数据组件的权限设计不够用
医疗数据的隐私属性非常强,患者姓名、身份证号、诊断信息、检验结果都属于敏感信息。医院信息科经常提出一个非常具体的要求:某科室的护士只能看到本科室患者的数据,医生只能看到自己诊疗过的患者,全院明细只有医务处和数据管理岗能看。
普通大数据组件那种“开个账号,给整个库的Select权限”的做法在这个场景下是过不了关的。没有行级权限,护士登录报表系统后能拖出其他科室患者的诊断记录,这不仅是流程问题,更是要出大事的合规问题。
5.2 Doris的权限体系怎么搭
Doris的用户管理基于RBAC,可以创建用户、授予或回收指定库表的权限。基础操作其实不难:
-- 创建只读账号并限制只能查ods层 CREATE USER 'query_user' IDENTIFIED BY 'StrongPass_123'; GRANT SELECT_PRIV ON ods.* TO 'query_user'; -- 创建BI账号,授予宽表查询权限 CREATE USER 'bi_report' IDENTIFIED BY 'AnotherPass_456'; GRANT SELECT_PRIV ON dwd.* TO 'bi_report';密码策略一定要强,空密码或者弱密码在医疗内网环境中一样危险。登录认证之外,我还会开启审计日志,把谁在什么时间跑了什么查询记下来,以便事后追溯。别小看这一步,真出了数据泄露事件,审计日志是最重要的排查依据。
5.3 行级权限的三种落地方式
Doris较新版本提供了一些行级权限能力,但在我们生产环境推进时,跨大版本稳定性优先,实际使用最多的是视图方案,因为它在任何版本都稳定可靠。
思路是:建一张“用户-科室”的映射关系表,再基于明细表创建视图,视图里join这张映射表,用当前登录用户过滤。这样每个医生登录之后,查询视图只能看到自己有权限的科室数据。
-- 假设user_dept_mapping存了用户和科室的关联 CREATE VIEW v_glucose_authorized AS SELECT g.* FROM dwd_glucose_detail g JOIN user_dept_mapping m ON g.dept_id = m.dept_id WHERE m.user_name = SUBSTRING_INDEX(CURRENT_USER(), '@', 1);把应用系统的连接用户改成BI只读账号,应用去查视图而不是原始表,权限就自动限制了。这个方案的好处是简单可靠,缺点是每次查询多一个join,有一定性能开销。如果表特别大,可以配合分区裁剪进一步缩小数据范围。
如果团队用的是支持行级策略的新版本,也可以直接使用官方行级权限特性,效果是一样的,但要注意仔细阅读版本说明和升级兼容性。
5.4 列权限:敏感字段要单独隔离
列级别权限更严格的做法是建“脱敏视图”。比如身份证号、手机号这种字段,绝大多数查询都不需要明文,可以在视图里只保留必要字段,或者用掩码函数把中间几位打码。Doris本身不支持类似MySQL的列级授权那么细的语法,但视图层完全可以实现同样的隔离效果。
实操上,我会分三层账号:运维管理员能看全部原始表,但保留给平台team;数据开发账号能访问明细表,但导出时走审计;业务账号只能访问脱敏视图,连接串配置在报表系统里,不对业务人员暴露。这个设计从第一天就要规划,不要等报表系统上线了再补,否则改权限、换连接串的工程量会大得吓人。
提示:不要把管理员账号给任何业务人员,也不要让报表系统用root级别的账号连接Doris。宁可多建几个账号,也不要图省事。
6. 慢查询调优实录:missing错误、谓词下推与执行计划解读
6.1 一个真实慢查询的完整排查过程
有一次,临床科室要求“近半年全院血糖监测趋势”,SQL看起来非常简单:按天分组,算出每天的测量次数和异常占比。但这个查询跑了两分钟都没出结果,直接把我叫过去了。
第一反应不是看SQL写得对不对,而是看表结构。结果发现两个问题:一是按measure_time分区没错,但查询条件里日期字段被业务系统包装成字符串,导致分区裁剪失效,扫了整个表的所有分区;二是分桶键是visit_id,但常用过滤条件是patient_id,两种过滤路径对不上。
我立刻用EXPLAIN查看执行计划,确认了最坏情况:扫描行数等于全表行数,没有走任何分区剪枝和分桶剪枝。
6.2 执行计划里藏着的关键信号
Doris的执行计划里,重点关注三件事:PartitionPrune是否触发、TabletScanNode的扫描行数是否大得离谱、Join的Build和Probe哪一侧数据膨胀。这些信息在EXPLAIN的输出里都能看到。
那次问题改起来其实不难:把分桶键从visit_id改成patient_id,同时把业务侧查询条件里的日期格式统一成标准YYYY-MM-DD HH:mm:ss。改完后同样的SQL,从两分钟降到了三秒左右。
还有一次是物化视图没有生效。物化视图只对匹配特定查询模式的SQL生效,查询条件变了,优化器可能就不走物化视图,这时候不能想当然。用EXPLAIN看一下是不是走对了,比在那里调半天参数更高效。
6.3 物化视图与Rollup:别建了一堆却用不上
物化视图和Rollup是Doris调优的重要工具,但有个前提:先梳理高频SQL,再针对性地建。我们的经验是,先跑一周线上查询日志,把Top 10慢SQL整理出来,看它们的共同维度组合和聚合模式,在这个基础上设计物化视图。
比如我们常查“科室+月份+平均血糖”,就先建一个按科室、月份预聚合的物化视图。查询命中后,性能提升非常明显。而Rollup对明细查询没有帮助,它只适合聚合查询。这个区分一定要搞清楚,否则你会陷入“为什么建了Rollup查询却没变快”的困惑中。
6.4 Presto连接Doris的missing错误:别让额外引擎背锅
热搜词里有“presto doris错误的missing”,这个我太熟了。业务方以前习惯用Presto做临时查询,后来我们统一用Doris之后,有段时间还保留了Presto连接Doris的方式。然后问题就来了:Doris表结构一变更,Presto那边时不时报missing schema、missing column之类的错误。
根因很清楚:Presto有自己的一套catalog元数据缓存,Doris里的表结构改变了,而Presto的元数据没有及时刷新,查询就找不到列了。解决方案也简单,要么在Presto侧刷新catalog,要么干脆统一让业务直连Doris,BI工具也直接配置Doris数据源,别在中间多叠一层。
我更推荐后者。临床分析场景里,Doris本身已经是计算引擎,前面再挂一个Presto,既增加元数据不一致的风险,又多一层网络和序列化开销。我们当时切换到直连模式之后,这类“灵异错误”就再也没出现过。
7. 一个完整的临床分析落地案例:从原始数据到报表看板
7.1 场景设定:病区血糖监测
这个案例我印象很深刻,因为它是我们整套Doris架构落地后做出来的第一个让临床科室真正“用起来”的看板。业务背景是:病区里糖尿病患者需要定时测血糖,数据分散在LIS系统、护理记录和HIS里。医务科想在一个大屏上看到全院所有病区的血糖监测覆盖率、异常率排名和单病人趋势。
7.2 数据链路设计:三条输入统一进Doris
设备自动上报的血糖测量记录走Kafka,用Routine Load持续写入Doris的ODS层。LIS每天导出的增量文件放对象存储,用Broker Load每天凌晨批量拉一次。HIS里的科室、病区、患者维度表用Stream Load做更新。
ODS层原样保留数据,DWD层做清洗和标准化,把“静脉血糖”“指尖血糖”统一换算成标准值,同时挂上科室、病区维度;ADS层存指标集,比如每个病区每天的监测人次、异常率、覆盖率。这样一个分层的结构,让报表查询和临时明细查询互不干扰。
7.3 核心查询与看板呈现
看板上最核心的两个查询,写出来其实很普通:
-- 近7天各病区血糖监测人数 SELECT dept_name, COUNT(DISTINCT patient_id) AS monitor_patients FROM dwd_glucose_detail WHERE measure_time >= NOW() - INTERVAL 7 DAY GROUP BY dept_name ORDER BY monitor_patients DESC; -- 近30天各病区血糖异常率排名 SELECT dept_name, SUM(CASE WHEN glucose_value > 11.1 OR glucose_value < 3.9 THEN 1 ELSE 0 END) / COUNT(*) AS abnormal_rate FROM dwd_glucose_detail WHERE measure_time >= NOW() - INTERVAL 30 DAY GROUP BY dept_name ORDER BY abnormal_rate DESC;这两个查询在Doris里都在秒级返回。大屏前端每五分钟刷新一次,后台没有任何数据预热,直接查明细表就能撑住。
7.4 性能对比:从24小时延迟到秒级呈现
老链路下,HIS的数据要经历“业务库->每日导出->Spark清洗->数据仓库->报表导出”,看板数据基本延迟24小时,而且每天早上第一次打开报表,最慢的查询能到十分钟。新链路用Routine Load加Broker Load,数据延迟降到分钟级;BI工具直连Doris,常见看板查询500毫秒到3秒。最直观的变化是:早交班会之前,报表已经在手机上打开了,没有护士长再在群里催数据。
8. 写在最后的实战经验清单
8.1 运维上必须盯住的事
Doris日常运维比Hadoop轻很多,但有几件事一定要盯。第一是tablet在BE节点上的分布是否均衡,可以用Doris提供的监控接口查询,如果某个BE节点上的tablet数量暴增,热点问题就会显现;第二是磁盘使用率和trash清理,Doris删表后数据会进trash,测试环境跑久了trash能占掉几十GB,要定时清理;第三是Compaction积压情况,如果导入频繁、Compaction跟不上,查询时IO会显著恶化。
8.2 项目复盘下来的几个重要判断
先跑通明细链路再谈优化。很多团队一上来就建模、就搞Aggregate表,结果业务规则一变,预聚合数据全部作废。先用Duplicate模型把明细流完整跑起来,业务满意了,再针对慢查询做物化视图,这个顺序更稳。
权限必须从第一天做起。我和不少同行交流过,没人觉得权限方案不重要,但总有人想着“先跑起来,后面再补”。一旦报表系统上线,业务跑起来了,再来改权限模型是一件非常伤筋动骨的事。
少叠一层就少一类问题。Presto、查询网关这些组件在特定场景有它的价值,但如果你已经有Doris这种完整的OLAP引擎,中间层越少,元数据不一致、网络开销、序列化开销这些坑就越少。临床数据分析这条路走得越久,我越觉得“把复杂留给自己,把简单留给业务”才是王道。