
医疗科技领域的临床数据分析和普通业务数据分析最大的不同在于数据源极其分散查询模型又必须极其严谨。HIS、EMR、LIS、PACS每套系统的输出格式都不一样诊断编码既有ICD-9也有ICD-10检验指标单位变来变去一条患者的完整画像要横跨好几年的就诊历史。我前前后后对比过 Hive、Presto、ClickHouse最终把核心分析引擎固定在了 Apache Doris 上并在真实的临床数据项目里完成了从集群部署到业务分析的全链路落地。这篇就把这段经历完整复盘一遍为什么临床场景需要 Doris、数据模型和分桶策略怎么定、集群怎么搭、数据怎么进、分析 SQL 怎么写、坑在哪里。给正在做医疗大数据或者毕业后准备进入这个方向的同学一个可以直接参考的路径。1. 为什么临床数据分析场景要选Doris而不是Hive或ClickHouse先聊选型。临床数据分析到底要处理什么样的查询我用实际业务拆一下大概就三类。第一类是固定看板指标比如全院实时门诊量、各科室住院率、病种构成比这类查询要求秒级甚至毫秒级返回不然院长办公室的大屏就会一直转圈。第二类是科研探索型查询医生想看看过去三年高血压合并糖尿病的患者用某种药物组合后的再入院率有什么变化这类查询往往要关联诊断、处方、检验三张事实表条件组合随机无法预聚合。第三类是审计追溯型查询比如某条异常检查记录要落到某个患者某天某台设备必须能精确查到明细字段。这三类需求叠加在一起传统方案就比较难受了。Hive 离线数仓胜在能扛超大扫描量但一个交互查询要等几十秒甚至几分钟医生根本没耐心Presto 做联邦查询灵活但本身不管理数据实时导入能力弱还容易出现各种引擎之间的元数据不一致问题ClickHouse 单表聚合确实快但多表 JOIN 在数据量和查询模式复杂以后限制很多临床数据恰恰是最典型的多表关联场景。Doris 的定位刚好卡在这个缝里。它是 MPP 架构加列式存储向量化执行单表聚合和多表 JOIN 都做得比较均衡支持 MySQL 协议这意味着医院现有的 BI 工具、报表系统、甚至很多开发人员写的 JDBC 代码可以直接复用学习成本低一大截。它同时支持高吞吐批量导入和低延迟实时写入一条门诊挂号流水从业务系统出来到 Doris 可查询秒级就能做到对监控类指标非常友好。再加上集群组件只有 FE 和 BE 两类节点没有像 Hadoop 生态那样复杂的依赖关系一个三人小团队就能运维得过来。我用一张表把当时几个候选方案的对比结论放出来方便你根据自己的业务体量判断引擎明细查询多表JOIN实时导入交互响应运维成本适用场景Hive/Spark一般能力强但慢弱分钟级高离线批量加工Presto强强弱秒级中联邦查询、多源临时分析ClickHouse强中等偏弱强毫秒级中单表单指标大宽表Doris强强强毫秒~秒级低交互式分析、统一分析数仓补充一个选型时的判断标准如果你的场景是写多读少、明细不查、只要聚合指标ClickHouse 完全够用如果是需要同时服务报表、即时查询、权限管控、数据服务接口的综合平台Doris 的综合分更高。临床数据分析恰恰属于后者既要给运营看大屏也要给临床医生做队列研究还要给程序员封装数据接口一个 Doris 集群就能把这些统一掉不用维护两套引擎。2. 临床数据模型设计分层、表模型与分桶策略2.1 从ODS到ADS临床数仓的四层架构大数据架构一般分四个层次数据采集层、数据存储计算层、数据服务层、数据应用层。落到临床数据场景我习惯把 Doris 里的库表也按这个思路分层。ODS 层是原始落地层医院各系统导出的数据几乎原样放进来只做最简单的格式转换比如把 CSV 里的日期字符串变成 DATE 类型。这一层保留一切原始字段目的是随时能溯源。DWD 层是清洗标准化层是我投入精力最多的地方要做诊断编码统一、检验单位归一、去除重复挂号记录、患者姓名身份证号脱敏。ADS 层是指标层面向具体的分析主题预聚合比如科室月度运营指标表、疾病谱统计表。数据服务层则直接让报表系统、数据接口查 ADS 和 DWD 层。这里有一个实践中的体会Doris 的查询能力强不代表可以省掉 DWD 层的清洗。临床数据如果不在入仓时把编码和单位理顺分析阶段你会被各种看起来一样其实不一样的数据坑到崩溃。比如血糖单位有的是 mmol/L 有的是 mg/dL转不转换直接决定统计结果差 18 倍。2.2 三种表模型在医疗场景怎么选Doris 的表模型是新手最容易懵的地方三种模型各有适用场景选错了后面要改表就得推倒重来。Duplicate Key 模型适用于明细事实表所有行原样保留。临床上的化验单明细、处方明细、手术记录明细都属于这类一行是一次事实即使出现完全相同的两行也是合法的比如同一天两个时间点的同一个检验项。Unique Key 模型适用于有更新语义的表比如患者档案、诊断记录新写入的同主键数据覆盖旧数据保证主键唯一。Aggregate Key 模型适用于指标统计表导入时直接做 SUM、MAX、MIN 等聚合减少存储量。我在项目里的实际选择是核心事实表全部用 Duplicate Key因为临床分析经常要回溯到行级明细一旦预聚合或者覆盖就丢了证据链患者主数据用 Unique Key保证身份证号或者院内 ID 唯一运营指标看板才用 Aggregate Key按天和科室汇总。这个选择牺牲了一点存储换来的是查询灵活性。创建一张就诊事实表的 DDL 可以这么写CREATE TABLE dwd_clinical_visit_fact ( visit_id VARCHAR(32) COMMENT 就诊号, patient_id VARCHAR(32) COMMENT 患者主索引ID, dept_code VARCHAR(10) COMMENT 科室代码, icd10_code VARCHAR(10) COMMENT 主诊断ICD10, visit_date DATE COMMENT 就诊日期, admission_dt DATETIME COMMENT 入院时间, discharge_dt DATETIME COMMENT 出院时间, total_cost DECIMAL(12,2) COMMENT 本次费用 ) DUPLICATE KEY(visit_id, patient_id) PARTITION BY RANGE(visit_date)( PARTITION p202201 VALUES LESS THAN (2022-02-01), PARTITION p202202 VALUES LESS THAN (2022-03-01) ) DISTRIBUTED BY HASH(patient_id) BUCKETS 24 PROPERTIES (replication_num 2);2.3 只有几MB数据是不是不需要分桶这是很多人问过的问题我直接给结论维表数据量小可以不分桶或者少量桶事实表哪怕当下只有几MB也建议按业务增长预规划别走极端。先说原理。Doris 的分桶是为了解决数据分布问题每个桶是一个 tablet物理上对应一个副本目录。分桶键选得好数据均匀打散在各 BE 节点上查询时可以并行扫描还能配合 Colocate Join 让关联发生在本地。如果数据只有几MB一张表只有 1 个桶也完全能跑查询不会因此变慢多少。问题在于医疗数据几乎是只增不减的一张就诊事实表上线三个月后可能就涨到几十 GB到时候发现桶数不够或者分桶键选错了要改分桶就必须新建表重导数据成本非常高。所以我的建议是事实表的分桶键在建模阶段就选好分桶数按至少一年的数据增量预估。选分桶键的标准是高基数且分布均匀patient_id 是天然好键因为患者 ID 散列均匀但 gender、dept_code 这种枚举值就千万别选分桶会变成某几个桶巨大、其他桶空转典型的数据倾斜。至于分桶数量经验值是让单个 tablet 的磁盘占用控制在 200MB 到 2GB 左右。如果预计一年数据量 100GB24 到 48 个桶是比较稳妥的选择。维度表比如科室表、编码表几十条数据我通常就用 3 个桶加一个副本保证高可用完全不用纠结。3. 集群部署与数据接入实操从Flume采集到Stream Load导流3.1 一个能跑临床业务的集群需要什么配置Doris 集群的部署策略这些年已经成熟很多了直接到 doris 官网下载标准发行版按二进制安装包就能拉起服务。实际生产环境我推荐至少 3 个 FE 节点加 3 个 BE 节点起步。FE 之间会自动选举出 Leader 和 FollowerLeader 负责写元数据Follower 提供读服务避免单点故障BE 节点负责数据存储和查询计算。FE 的内存建议给到 16GB 以上因为元数据都常驻内存临床项目如果建了上千张表、几十万个分区元数据量不小。BE 节点内存建议 64GB 起步磁盘用 SSD列式存储在范围扫描上的 IO 优势非常明显。副本数在 PROPERTIES 里设置核心表我一般设置 replication_num 2也就是数据在两个 BE 上各存一份。有一个容易漏的细节副本数不能超过 BE 节点数如果你只有 1 个 BE 却设置了 2 副本建表会直接报错这是新手部署时的典型问题。启动流程本身不复杂先启动 FE然后用 MySQL 客户端连接 FE 的 9030 端口执行 ADD FABRICATE 命令把各 BE 注册进去。注册完成后可以在 SHOW BACKENDS 里看节点状态Alive 字段为 true 才算成功。我当初第一次搭集群就栽在这个地方FE 起来了但迟迟没把 BE 加进去结果建表一直提示没有可用节点排查了半天才发现是注册环节漏了。3.2 数据从业务系统到Doris的三条链路临床系统的数据来源五花八门接入方式不能只押注一种。我的项目里实际跑着三条链路。第一条是实时链路用于门诊挂号、收费、检验报告这类高时效数据。业务系统把数据发到 KafkaDoris 用 Routine Load 任务持续消费近实时入库。Routine Load 的好处是任务常驻挂了会自动重试不用人工干预。创建任务的 SQL 大致长这样CREATE ROUTINE LOAD rl_visit ON dwd_clinical_visit_fact COLUMNS(visit_id, patient_id, dept_code, icd10_code, visit_date, admission_dt, discharge_dt, total_cost) FROM KAFKA( kafka_broker_list 10.0.0.1:9092,10.0.0.2:9092, kafka_topic ods_clinical_visit, property.kafka_default_offsets OFFSET_BEGINNING );第二条是半实时链路用于医院系统以文件形式推送的批量数据比如每天凌晨导出的检验记录。这里我惯用的组合是 Flume 采集 Stream Load 导入。Flume 监控接口目录的文件把新文件抓出来做简单格式改写然后通过 HTTP 方式调用 Doris 的 Stream Load 接口写入。Stream Load 的调用方式非常直接返回的 JSON 里有 Status 字段看到 Success 就说明这批数据写成功了。curl 命令大概是curl --location-trusted -u admin:password \ -H label:visit_20240115_001 \ -H column_separator:, \ -T /data/cleaned_visit.csv \ http://fe_host:8030/api/dwd/dwd_clinical_visit_fact/_stream_load第三条是离线批量链路用于历史数据回填。比如上系统之前积压了三年的历史病历这些数据量大且不需要实时我通常会先用 Spark 做一次清洗转换生成 Parquet 文件放到 HDFS再用 Broker Load 导入 Doris。这样做的好处是清洗逻辑在 Spark 里可以写得很复杂Doris 只做存储和查询职责清晰。3.3 导入时的清洗细节不管哪条链路我在导入前都会处理三件事。第一是列裁剪原始接口字段有四十多个很多是分析用不上的备注文本导入时直接不映射到表里列式存储省下的空间非常可观。第二是类型归一日期字段统一成 yyyy-MM-dd金额统一成 DECIMAL(12,2)避免浮点误差。第三是设置容错率Stream Load 和 Routine Load 都支持 max_filter_ratio 参数允许 1% 的脏数据跳过而不是让整个任务失败。医疗数据里偶尔会出现格式错乱的记录这个参数能保证导入任务不被一条坏数据卡死。Doris 导入任务默认是有原子性的同一个 label 的任务要么全成功要么全失败。批量导入失败时先查 SHOW LOAD 的日志确认是哪一行触发的而不是直接改数据重导——重导时换个 label防止和之前的失败任务冲突。4. 临床分析核心SQL实战从就诊趋势到患者画像4.1 月度就诊量与疾病谱变化临床管理层最常看的就是这个月门诊量涨了还是跌了哪些病种在增加。这类查询在 Doris 里写起来很直观核心是 GROUP BY 加日期格式化再看一下各疾病分组的去重患者数SELECT DATE_FORMAT(visit_date, %Y-%m) AS stat_month, icd10_code, COUNT(DISTINCT patient_id) AS patient_cnt, COUNT(*) AS visit_cnt FROM dwd_clinical_visit_fact WHERE visit_date DATE_SUB(CURDATE(), INTERVAL 1 YEAR) GROUP BY stat_month, icd10_code ORDER BY stat_month, patient_cnt DESC;这里有两点经验。第一COUNT(DISTINCT patient_id) 在数据量大时会比较吃力因为需要精确去重。对千万级以上量级可以改用 Bitmap 类型配合 BITMAP_UNION_COUNT导入时把 patient_id 转成 Bitmap去重速度会快一个数量级。第二Doris 支持在表上建 Rollup 或者物化视图如果这类月度疾病统计是高频查询可以针对 (stat_month, icd10_code) 组合建一个 Rollup查询会自动路由到更小的聚合表里扫描的数据量少很多。4.2 科室运营指标住院日与费用分布住院日和次均费用是科室考核的硬指标。Doris 的窗口函数支持得比较全下面这个查询按科室算平均住院日同时用 PERCENTILE 函数算出中位数避免少数超长住院患者把平均值拉高失真SELECT dept_code, AVG(DATEDIFF(discharge_dt, admission_dt)) AS avg_los, PERCENTILE(CAST(DATEDIFF(discharge_dt, admission_dt) AS INT), 0.5) AS median_los, SUM(total_cost) / COUNT(DISTINCT visit_id) AS avg_cost_per_visit FROM dwd_clinical_visit_fact WHERE discharge_dt IS NOT NULL AND discharge_dt 2024-01-01 GROUP BY dept_code ORDER BY avg_los DESC;实际跑下来百万级数据量的这个查询在 Doris 里基本是两秒内返回。我一开始习惯用 AVG 直接算后来发现中位数和平均数的组合更能说明问题。比如某科室平均住院日 9 天中位数只有 5 天说明有一小撮超长住院患者把平均值拉高了管理者需要关注的是那部分患者而不是全体。4.3 诊断-用药关联分析一次典型的多表JOIN医生做药物疗效观察时最典型的查询是用过A药的高血压患者对比没用过的再入院率有没有差异。这需要把就诊事实表、处方明细表、诊断表关联起来。Doris 对这种多表 JOIN 的优化做得比较好配合 Colocate Join只要关联键在表定义时用了相同的分桶方式数据在本地就能完成 join不产生跨节点数据传输。WITH base AS ( SELECT v.patient_id, v.visit_id, v.admission_dt, v.discharge_dt, d.icd10_code FROM dwd_clinical_visit_fact v JOIN dim_diagnosis d ON v.visit_id d.visit_id WHERE d.icd10_code LIKE I10% AND v.visit_date BETWEEN 2023-01-01 AND 2023-12-31 ), drug AS ( SELECT visit_id FROM dwd_prescription_detail WHERE drug_generic_name LIKE %氨氯地平% ) SELECT CASE WHEN p.visit_id IS NOT NULL THEN 用A药 ELSE 未用A药 END AS drug_flag, COUNT(DISTINCT b.patient_id) AS patient_cnt, COUNT(DISTINCT b.visit_id) AS visit_cnt FROM base b LEFT JOIN drug p ON b.visit_id p.visit_id GROUP BY drug_flag;写这类 SQL 时有几个注意点。关联键最好都选 visit_id 这类高基数均匀的字段join 条件里尽量避免在字段上套函数比如 LEFT(visit_id, 10) LEFT(...)会导致 Doris 无法走哈希分桶优化。还有一点Doris 的 COUNT(DISTINCT) 和 JOIN 组合在大数据量下比较吃资源如果只是想看分组数量可以先在小结果集上再聚合而不是在最内层做全量去重。4.4 患者复诊行为分析窗口函数的使用临床随访分析里经常要看一个患者一段时间内来了几次多久没来了这类问题窗口函数是标准解法。下面的 SQL 用 ROW_NUMBER 给每个患者的就诊记录按时间排序然后筛出每个患者的首次和末次就诊计算出就诊间隔SELECT patient_id, DATEDIFF(MAX(visit_date), MIN(visit_date)) AS treatment_span_days, COUNT(*) AS visit_times FROM dwd_clinical_visit_fact WHERE visit_date 2024-01-01 GROUP BY patient_id HAVING COUNT(*) 3 ORDER BY visit_times DESC;从这步进一步扩展就能做患者分层一年只来一次的是偶发就诊来三四次的是常规随访超过十次的高频患者可能病情复杂或者依从性有问题。这个结果导入到可视化系统里配合 Flask 加 ECharts 画一个患者流向桑基图比任何汇报 PPT 都直观。Doris 对可视化系统来说就是一个兼容 MySQL 协议的 JDBC 数据源ECharts 的前端直接请求后端 Flask 接口后端查询 Doris 返回 JSON几分钟就能搭完一个分析看板。4.5 数据可视化层的对接技巧我这里多说一句可视化层。Doris 的 9030 端口就是 MySQL 协议端口SQLAlchemy 里用 mysqlpymysql 连接就行不需要额外驱动。唯一要注意的是连接池配置报表看板页面上经常有多个图表同时刷新每个图表一个查询如果连接池太小页面会出现偶发加载失败。我在 Flask 里把连接池调到 50并且给关键看板查询设置独立的超时时间避免一个慢查询把连接池占满拖死其他图表。5. 常见问题与排查技巧实录5.1 missing类报错到底在说什么我用 Presto 和 Doris 混合用过一段时间最常踩的报错就是类似 table xxx missing in catalog 或者 column xxx missing 的提示。这类问题多半出在元数据同步上而不是数据真的丢了。常见原因有三类。第一类是 Presto 的 Doris Catalog 配置里库名或者表名大小写没对上。Doris 的库表默认不区分大小写但 Presto 端会区分两边配置不一致就会报 missing。解决办法是到 Presto 的 etc/catalog 里检查 Doris Catalog 配置把 properties 里的库表名改成实际存在的名称同时在 Doris 端执行 SHOW TABLES 确认大小写。第二类是 Doris 表结构刚好做了变更比如加了列而 Presto 侧的元数据缓存还是旧结构查询新列就会报 missing column。执行 Presto 的 REFRESH METADATA 或者重启 Coordinator 清理缓存就好了。第三类是建表时生成了物化视图或者 Rollup查询优化器路由到了不存在的列组合这种直接在 Doris 侧 SHOW CREATE TABLE 检查 DDL 即可。排查这类报错有个通用路径先确认 Doris 自身能查到数据再逐层检查连接引擎的元数据缓存。用 SHOW TABLES、DESC tablename、SHOW CREATE TABLE 三连基本能定位 80% 的问题。5.2 分桶键选错导致的数据倾斜和查询卡顿如果你发现某个查询偶尔慢得离谱其他查询都正常大概率是数据倾斜。我遇到过一张表的分桶键选了科室代码结果内科几十万条、整形科几百条数据全部堆在一两个 tablet 上查询时别的节点闲着那一个节点累死。排查方法是到 BE 的 tablet 元数据里看各 tablet 的行数分布或者用 tablet 级别的 profile 看扫描耗时。解决办法只能重建表换分桶键这也是为什么我在前面反复强调分桶键要选 patient_id 这类高基数字段。如果你已经建了表并且数据量不大趁早重建换键拖得越久迁移成本越高。5.3 导入任务失败怎么定位Stream Load 和 Broker Load 失败时Doris 都会在返回的 JSON 里带 ErrorURL这个 URL 指向具体的错误行文件。我一开始只看 Status 列发现 Failed 就去重导后来才知道一定要打开 ErrorURL 看明细。常见错误就是类型不匹配比如字符串列的末尾带了换行符或者日期字段是空的被严格模式拦下来。处理方式有两个如果坏数据比例很低直接调大 max_filter_ratio如果比例高就得回清洗流程里面修数据不能靠容错硬吃。5.4 大查询内存溢出临床科研查询经常很奔放比如把三年明细全部关联一遍这种查询在 Doris 里可能被内存限制挡下来报 Memory limit exceeded。我的处理习惯是给科研账号单独设置查询内存上限比如 30GB同时把这类重查询安排在低峰期执行必要时在查询前加 SET exec_mem_limit ... 调大单查询内存。另一个技巧是给大查询做预聚合——能按月过滤就先按月过滤不要一上来就全表扫描。临床数据的分布天然带时间属性大部分分析窗口就是近一年两年分区裁剪做好了查询效率能翻好几倍。5.5 数据质量检查是最后一道防线临床数据进门之后必须持续做质量巡检我整理了一张简单但救过我好几次的质量检查清单你可以直接拿去用检查项对应SQL思路期望结果重复就诊记录按visit_id分组COUNT(*) 1无空主诊断主诊断字段为空且非急诊类型极少日期逻辑错误出院时间早于入院时间无编码不存在关联标准编码维表LEFT JOIN找NULL极少费用为负total_cost 0无这些检查挂在每日调度里跑出异常就告警。医疗数据出错不是小事一份错误统计可能直接影响临床研究结论所以质量检查框架这块不能省。最后说一点我在实际项目里最深的感受。Doris 这些年迭代很快功能越来越全但工具始终只是工具临床数据项目的成败更多取决于数据规范性和建模的克制。不要在初始阶段就把表建得很花哨先把明细层做扎实分桶选对导入链路稳定后续的分析和可视化自然水到渠成。如果你正准备用 Doris 搭一套临床数据分析平台照着这个路径走能少走很多弯路。