大多数工程师永远不会写生产 MUMPS 代码。我写过——在 VistA 的 Pharmacy 和 Billing 模块里,跑在基于 Linux 的 FIS GT.M 上,规模覆盖国家级医疗。每次我在会议上说这件事,都有人问我是不是在开玩笑。我没有开玩笑。这是它实际上的样子。
VistA 不是一个抽象概念,它是一个 codebase
VistA(Veterans Health Information Systems and Technology Architecture)是 VA 的开源 EHR。从 1970 年代起就在持续开发。它的 Pharmacy 模块是地球上最古老、最经过战斗考验的药物配发系统之一。当我说”经过战斗考验”时,我的意思是:它熬过了 40 多年的 VA 程序员、预算周期、MUMPS 版本、硬件迁移和国会。
这个 codebase 不漂亮。它不是设计出来的。它是积累出来的——被已经死去或退休的人层层叠叠、修补、扩展。例程里有 1987 年的注释头。你会看到 ; MODIFIED BY DPT 3/12/89 — DO NOT DELETE,而你绝对不会删掉它下面的东西,因为你不知道它做什么,在职的人也没人知道。
这就是生产在拥有 40 年在线时间时的样子。
GT.M 是 MUMPS,但它在 Linux 上跑而且非常快
当人们说”MUMPS”时,他们通常指两个运行时之一:InterSystems 的 Caché(现在是 IRIS),或者 FIS(前身是 Greystone Technology,所以叫 G)的 GT.M。GT.M 是开源的,在 Linux 上运行,是大多数 VistA 安装使用的。它是 VA 使用的运行时。也是我使用的运行时。
GT.M——我在这里想谨慎措辞——作为存储引擎是真正令人印象深刻的。它把 MUMPS globals 实现为磁盘上的 B 树,带日志级别的崩溃安全性。一个 SET ^PSDRUG(drugIen,"QTY")=qty 不是对数据库服务器的往返调用。它是对本地 B 树的写入,GT.M 会刷新和记录日志。执行 MUMPS 例程的进程和存储引擎是同一个进程。没有网络跳跃。没有 ORM。没有查询规划器。
这听起来像玩具,直到你在看配发吞吐量基准测试,想知道为什么一个 1990 年代的系统在普通硬件上以写密集型工作负载跑赢了你亮闪闪的 Spring Boot API。然后它就清楚了:应用逻辑和比特之间什么都没有。
写 Pharmacy 例程实际上是什么样子
Pharmacy 模块管理药物库存、配发订单、药物相互作用检查和静脉输液混合记录。用 MUMPS。在 ^PS* globals 上(PS = Pharmacy System)。
一个配发例程大概长这样:
DISPENSE(DFN,DRUG,QTY) ;dispense DRUG to patient DFN
N RESULT,AVAIL
L +^PSDRUG(DRUG,"STOCK"):5 E D ERRLK Q
S AVAIL=$G(^PSDRUG(DRUG,"QTY"))
I AVAIL<QTY D INSUF Q
S ^PSDRUG(DRUG,"QTY")=AVAIL-QTY
S ^PSDRUG(DRUG,"LAST")=$$NOW^XLFDT()
S RESULT="OK"
L -^PSDRUG(DRUG,"STOCK")
Q RESULT
尽管盯着它看。我等着。
N 是 NEW(局部变量作用域)。L +^GLOBAL:timeout 是获取锁。$G() 是带默认值的 GET(永远不会有空指针错误,MUMPS 有 $G)。S 是 SET。Q 是 QUIT。$$ 调用外在函数——$$NOW^XLFDT() 调用 XLFDT 例程里的 NOW 标签,返回 FileMan 格式的时间戳(FileMan date 是另一篇完整的文章,别让我开始讲)。
global ^PSDRUG 是层次化的:顶层下标是药物内部条目号(IEN),下面有命名子键,如”QTY”、“STOCK”、“LAST”。这就是 schema。没有 schema 文件。schema 隐式在代码里。你通过读代码和用 GT.M 的 D ^%G 工具盯着 globals 来学习它。
药物相互作用检查是一头特别的野兽
我在 VistA 写过的最让人焦虑的代码与药物相互作用检查有关。^PSSDI 和 ^PSDRUG globals 持有相互作用数据,Pharmacy 模块有在配发订单被确认之前触发的例程,检查新药物是否与患者活跃用药列表里已有的任何东西有相互作用。
让这件事有压力的不是 MUMPS。MUMPS 只是语法。让这件事有压力的是这个逻辑是安全网。没有下游服务在双重检查你的工作。例程跑完,药剂师看到结果,如果你的相互作用检查逻辑有 bug,一个本应被标记的药物组合就被配发了。
我对那些例程进行了偏执狂式的测试。GT.M 的 M-Unit(一个如果 JUnit 是 1995 年设计的会是什么样子的测试框架)成了我最好的朋友。我用已知相互作用设置场景——华法林和阿司匹林、甲氨蝶呤和 NSAIDs——反复跑直到标记一致。这可能是我职业生涯中对一段代码最仔细的时候。
HL7 消息包是事情开始变奇怪的地方
VistA 的 HL7 包在某些情况下比 HL7 v2 规范清理早了几年。解析和生成 HL7 消息的例程在 MUMPS 里做字符串切片——$E(MSG,start,end) 提取段,_ 连接,piece 函数在分隔符上拆分。
我在那里发现了:患者人口学段里的边缘情况,带着来自显然再没回来修复它们的人的 ; KLUDGE — MO WILL FIX LATER 注释。我修了一些。给其他的留下了笔记。这就是遗留代码存活的方式——不是靠被清理,而是靠在承重的临时方案周围积累越来越有信息量的注释。
AFAQ 的 GT.M 数据库 connector 工作
在 EHS 的 VistA 时间之后,我去了 AFAQ,我们在部分基于 VA VistA 组件构建 EHR/EMR 套件。我立刻遇到的缺口:GT.M 没有现代数据库 connector。没有 JDBC。没有 REST 适配器。Java 应用或 Web 前端没有办法与它通信,除非跌入原始 MUMPS 或使用一个追溯到 90 年代的古老基于 TCP 的 RPC 层。
所以我构建了新的 connector。挑战在于 GT.M 的原生访问机制要么是进程内的 MUMPS,要么是它的 $gtm_dist C API——一个让你从 C 调用 GT.M 例程和访问 globals 的 C 库。我们把它包在 JNI 层里,这样 Spring Boot 后端可以直接调用它。不漂亮。跨 JNI 边界的错误处理是一段特别有创意的经历。但它有效,而且它把 EHR 的病历加载时间从”尴尬”降到了”可接受”。
那个项目更深层的洞见:GT.M 之所以快,是因为它简单。你在它上面加的每一层抽象——REST、JNI、TCP RPC——都会消耗一些那种简单性。技巧是添加恰好够用的抽象,让消费系统能与它通信,不多。
我的收获
在国家级医疗系统里写生产 MUMPS 不是我推荐的职业路径。我也不会用它换别的什么。
它教会了我现代后端工作教不了的东西:
- 存储成本总是存在;大多数语言只是把它隐藏了。 在 MUMPS 里,每个
S ^global(key)=value都是一次存储操作。你永远不会忘记写入是有代价的。在 Java 里,你不断忘记这一点,直到你的 Hibernate session 开始每次请求做 400 个查询。 - Schema 设计即便没有 schema 文件也还是设计。 你的 MUMPS global 下标的层次结构就是你的数据模型。糟糕的键选择会级联成糟糕的访问模式,你没法在不重写所有读取那些 globals 的代码的情况下修复它。
- 崩溃安全比你想的更重要。 GT.M 的日志意味着 VistA 安装运行多年而不发生数据损坏事件。我的 Postgres 数据库需要仔细的事务卫生才能实现同样的保证。MUMPS 模型把安全路径变成了默认路径。
我现在主要写 Java 和 TypeScript。我的数据库是 Postgres。我的运行时有 GC。但当我评审一个 schema 设计或争论事务边界时,我大脑的某个部分还在那个 GT.M shell 里,盯着 ^PSDRUG globals,想配发高峰时凌晨三点的读取会是什么样子。
那不是怀旧。那是教育。