
简介这份资料面向医疗信息化开发者与HIS系统运维人员聚焦InterSystems Caché数据库在医疗场景下的开发与落地帮助读者从环境搭建、数据建模到高可用部署形成完整认知。压缩包共145个文件以143份PDF技术文档为主辅以1个HTML索引页和1张示意图整体约77.18MB涵盖基础教程、数据模型设计指南、MUMPS语言开发者手册、API参考、高可用与灾难恢复策略、安全性管理及报表分析工具说明等模块。内容还包含实例代码与示例项目便于对照练习快速理解多维数据模型、Web服务集成与权限加密等关键机制。目前已有1031人学习下载适合希望系统掌握Caché开发、优化HIS性能与提升医疗服务连续性的技术人员参考。1. 医疗HIS系统里Caché 数据库开发到底在做什么HIS 系统里最怕的不是功能多而是门诊高峰期挂号、医嘱、收费三条业务线同时压到数据库上响应一旦超过两秒窗口就开始排队。很多三甲医院的老 HIS 跑在 Caché 上十几年不换不是因为怀旧而是它在高并发事务和层级化数据模型上的表现确实扛得住。Caché 是 InterSystems 推出的后关系型数据库底层是 M 语言也叫 MUMPS支持多维数组直接映射、对象持久化和 SQL 访问三种模式。做 HIS 开发你面对的不是一张张二维表而是一棵棵以患者 ID、就诊号、医嘱号为主干的全局变量树。这篇文章面向正在或即将接手 HIS 二开、接口对接、报表取数的工程师把 Caché 开发文档里最常翻的那几页拆开讲清楚怎么连、怎么写、怎么调、怎么不翻车。2. Caché 数据模型与 HIS 业务表的映射关系2.1 全局变量树HIS 里患者主索引为什么不用关系表关系型数据库里患者主索引是一张 Patient 表主键 PatientID外键挂就诊、医嘱、检验。Caché 的做法是把 PatientID 直接作为全局变量的第一层下标下面挂所有关联数据。比如^PAPER(PatientID,Name)、^PAPER(PatientID,DOB)就诊记录挂在^PAADM(EpisodeID)下医嘱挂在^OEORD(OrderID)下。这种结构在 HIS 里的优势是取一个患者的全部信息不需要 JOIN 七八张表直接顺着下标往下读就行I/O 次数少缓存命中率高。但代价是你不能再靠 SQL 的思维去写查询。很多从 MySQL 转过来的开发者第一反应是SELECT * FROM Patient WHERE Name LIKE %张%在 Caché 里如果没建索引这就是全表扫描全局变量数据量一大直接拖垮生产库。HIS 厂商通常会在^PAPER上建 Name 索引索引本身也是全局变量比如^PAPERi(Name,张,PatientID)查询时先走索引再回表。2.2 用 SQL 访问 Caché什么时候该用什么时候不该用Caché 支持通过 ODBC/JDBC 用 SQL 访问但底层执行计划和你写的关系型 SQL 差别很大。适合用 SQL 的场景报表取数、批量导出、和外部系统做数据交换。不适合的场景高频事务写入、单患者实时查询、需要锁控制的业务逻辑。下面是一个通过 JDBC 查 Caché 的 Java 示例查某个科室当天的就诊列表// Caché JDBC 连接示例查科室当日就诊 Class.forName(com.intersys.jdbc.CacheDriver); String url jdbc:Cache://10.0.0.10:1972/HISDB; Connection conn DriverManager.getConnection(url, _SYSTEM, SYS); // 注意PAADM 是就诊主表AdmDate 是就诊日期DeptCode 是科室代码 String sql SELECT EpisodeID, PatientID, AdmTime FROM PAADM WHERE DeptCode ? AND AdmDate ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, D001); ps.setDate(2, java.sql.Date.valueOf(2025-01-15)); ResultSet rs ps.executeQuery(); while (rs.next()) { System.out.println(rs.getString(EpisodeID) / rs.getString(PatientID)); }逻辑说明这段代码走的是 Caché 的 SQL 网关PAADM是 HIS 里就诊表的类名实际映射到全局变量^PAADM。参数说明DeptCode和AdmDate上必须有索引否则这条 SQL 会退化成全扫描。判断有没有索引可以在 Caché 终端里执行DO $SYSTEM.SQL.Explain(SELECT ...)看执行计划如果出现Read master map而不是Read index map就要找 DBA 加索引。2.3 直接操作全局变量M 语言读写的最小闭环HIS 二开里最常做的操作是直接读写全局变量比如给患者加一个扩展字段、更新某个医嘱状态。M 语言的基本语法是SET、WRITE、KILL、$ORDER、$GET。下面是一个在 Caché 终端里直接操作的例子; 读取患者姓名如果不存在返回空字符串 SET name $GET(^PAPER(12345,Name)) WRITE 患者姓名, name, ! ; 遍历某个患者的所有就诊号 SET epID FOR SET epID $ORDER(^PAADM(PatIndex,12345,epID)) QUIT:epID DO . WRITE 就诊号, epID, 时间, $GET(^PAADM(epID,AdmTime)), !逻辑说明$GET是安全读取变量不存在时返回空串避免UNDEFINED报错。$ORDER是按下标顺序遍历QUIT:epID是当遍历到空值时退出循环。参数说明^PAADM(PatIndex,12345,epID)是患者到就诊的索引全局实际 HIS 里索引名可能不同需要查该系统的全局变量映射文档。注意在生产库终端里直接SET写数据前务必先WRITE确认当前值并且确认没有其他进程在锁这条记录。3. 从零跑通一个 Caché 开发环境安装、连接、第一个类3.1 本地装 Caché 还是连测试库选型与版本对齐Caché 的安装包分 Windows 和 Linux 两个主流版本HIS 厂商用的版本通常是 2016.2 到 2018.1 之间的 LTS。本地开发环境建议和医院生产库版本保持一致否则类方法编译可能报错。如果拿不到安装包退而求其次连厂商提供的测试库但要注意测试库的数据量和生产库差几个数量级性能问题在测试库上复现不出来。安装时有两个参数必须确认一是Maximum per-process memory默认值偏小跑批量任务容易STORE报错二是Global buffer pool sizeHIS 库建议设到物理内存的 40% 左右。安装完成后用ccontrol start cache启动实例浏览器访问http://localhost:57772/csp/sys/UtilHome.csp进管理门户。3.2 用 Studio 还是 VS Code开发工具链怎么选老一代 HIS 开发者习惯用 Caché Studio它和 Caché 服务端绑定支持类编辑、编译、调试、全局变量查看。缺点是只支持 Windows而且对新版 VS Code 的插件生态不友好。现在比较常见的做法是日常写代码用 VS Code 加 InterSystems ObjectScript 插件调试和全局变量查看还是回 Studio 或终端。VS Code 插件的配置关键是objectscript.conn这一段指向 Caché 的 SuperServer 端口默认 1972{ objectscript.conn: { host: 10.0.0.10, port: 1972, username: _SYSTEM, password: SYS, ns: HISAPP } }参数说明ns是命名空间HIS 系统通常按业务模块分命名空间比如HISAPP放应用类HISDATA放数据全局。连错命名空间会导致类找不到或全局变量读不到。注意生产环境的_SYSTEM密码不要硬编码在配置文件里用环境变量或密钥管理。3.3 写第一个持久化类从建类到存一条就诊记录Caché 的持久化类用%Persistent继承属性用Property定义编译后自动生成对应的全局变量存储结构。下面是一个简化的就诊记录类Class HIS.Admission Extends %Persistent { /// 就诊号 Property EpisodeID As %String(MAXLEN 30) [ Required ]; /// 患者ID Property PatientID As %String(MAXLEN 30); /// 就诊科室 Property DeptCode As %String(MAXLEN 10); /// 就诊时间 Property AdmTime As %TimeStamp; /// 就诊类型门诊/急诊/住院 Property AdmType As %String(MAXLEN 10); /// 按患者ID查就诊记录 Query ByPatient(pid As %String) As %SQLQuery(CONTAINID 1) { SELECT EpisodeID, DeptCode, AdmTime FROM Admission WHERE PatientID :pid } }逻辑说明Extends %Persistent让这个类可持久化编译后 Caché 自动生成^HIS.AdmissionD和^HIS.AdmissionI两个全局变量D 是数据I 是索引。Query定义了一个命名查询可以在 ObjectScript 里用%ResultSet调用也可以在 SQL 里当视图用。参数说明MAXLEN控制字段最大长度HIS 里 EpisodeID 一般不超过 30 字符Required表示非空约束插入时缺这个字段会报错。存一条记录并查询; 创建并保存一条就诊记录 SET adm ##class(HIS.Admission).%New() SET adm.EpisodeID EP20250115001 SET adm.PatientID P12345 SET adm.DeptCode D001 SET adm.AdmTime $ZDATETIME($HOROLOG,3) SET adm.AdmType 门诊 SET sc adm.%Save() IF $$$ISERR(sc) DO $SYSTEM.Status.DisplayError(sc) QUIT ; 用命名查询取该患者的就诊列表 SET rs ##class(%ResultSet).%New(HIS.Admission:ByPatient) DO rs.Execute(P12345) WHILE rs.Next() { WRITE rs.Get(EpisodeID), / , rs.Get(DeptCode), ! }逻辑说明%New()创建内存对象%Save()持久化到全局变量返回状态码用$$$ISERR判断。%ResultSet是 Caché 里执行查询的标准方式Execute传参Next逐行取。参数说明$ZDATETIME($HOROLOG,3)是 Caché 内部时间格式转 ODBC 时间戳3表示 ODBC 格式。注意%Save()失败时一定要看sc里的错误信息常见的是唯一索引冲突和必填字段为空。4. HIS 场景下的 Caché 性能调优与并发控制4.1 全局变量命名规范为什么你的查询比别人慢十倍HIS 里全局变量的命名直接决定查询性能。常见的好命名是^PAPER(PatientID,Name)这种以业务主键为第一下标的扁平结构。差的命名是^HIS(2025,01,15,D001,P12345)这种把日期、科室、患者层层嵌套的结构查一个患者要遍历整个日期树。我见过一个翻车案例某模块把医嘱存在^ORD(Year,Month,Day,Dept,Patient,OrderID)下日常查询按患者查结果每次都要从 Year 开始$ORDER遍历到 Patient 层数据量上来后单次查询超过 5 秒。后来改成^ORD(PatientID,OrderID)加^ORDi(Dept,DeptCode,OrderID)索引查询降到 50 毫秒以内。判断命名是否合理看两个指标一是最常用的查询条件是不是第一下标或索引下标二是单次查询的$ORDER循环次数是否可控。如果循环次数随数据量线性增长就要考虑加索引全局。4.2 锁与事务医嘱提交时怎么避免脏读和死锁Caché 的锁用LOCK命令事务用TSTART/TCOMMIT。HIS 里最典型的并发场景是医生开医嘱、护士校对、收费扣费三个操作同时操作同一条医嘱记录。如果不加锁可能出现护士校对时读到医生还没提交的半条医嘱。; 医嘱状态更新加锁 - 事务 - 更新 - 提交 - 解锁 LOCK ^OEORD(OrderID):5 IF $TEST { WRITE 获取锁超时医嘱可能被其他进程占用, ! QUIT } TSTART SET $ZTRAP Rollback^MyRoutine SET ^OEORD(OrderID,Status) 已校对 SET ^OEORD(OrderID,VerifyTime) $HOROLOG SET ^OEORD(OrderID,VerifyUser) UserID TCOMMIT LOCK -^OEORD(OrderID) QUIT Rollback TROLLBACK LOCK -^OEORD(OrderID) WRITE 更新失败已回滚, ! QUIT逻辑说明LOCK ^OEORD(OrderID):5是尝试加锁超时 5 秒$TEST为 0 表示没拿到锁。TSTART开启事务$ZTRAP设置错误陷阱任何一步出错跳到 Rollback 标签回滚。参数说明锁超时时间根据业务容忍度设门诊场景建议 3 到 5 秒住院场景可以放宽到 10 秒。注意LOCK的粒度要尽量小锁整个^OEORD全局会导致所有医嘱操作串行化锁单条^OEORD(OrderID)才是正确做法。4.3 查询优化用 $ORDER 还是用 SQL 索引同一个查询需求用$ORDER遍历全局变量和用 SQL 走索引性能可能差几十倍。判断原则如果查询条件是全局变量的下标或索引下标用$ORDER直接遍历最快如果查询条件涉及多个字段的组合筛选用 SQL 让 Caché 的查询优化器选索引。比如查某患者某天的所有检验结果如果全局变量是^LAB(PatientID,Date,TestCode)直接$ORDER遍历^LAB(PatientID,Date,TestCode)就行。但如果要查“所有科室里血糖超过 10 的患者”这种跨患者、带值筛选的查询就要靠 SQL 索引在TestCode和ResultValue上建索引。一个实用的技巧在 Caché 终端里用DO ^%G查看全局变量结构用DO ^%SYS.SQL或$SYSTEM.SQL.Explain看 SQL 执行计划。如果执行计划里出现Temp file或Master map scan说明索引没走对需要调整。5. Caché 开发避坑清单从终端误操作到版本兼容5.1 坑一在生产终端直接 KILL 全局变量现象在 Caché 终端里执行KILL ^PAPER(12345)想删一条测试数据结果发现这个患者的所有就诊、医嘱、检验记录全没了因为^PAPER(12345)下面挂了整棵患者树。原因Caché 的KILL是递归删除KILL ^PAPER(12345)会删掉^PAPER(12345)及其所有子节点。很多人从关系型数据库转过来以为KILL只删一行。解决删数据前先用$ORDER或$DATA确认要删的范围。如果只想删某个字段用KILL ^PAPER(12345,Name)指定到叶子节点。生产库操作前先在测试库跑一遍或者用DO ^%G先看结构。5.2 坑二类编译后全局变量名和预期不一致现象定义了一个HIS.Patient类以为数据存在^HIS.Patient下结果在终端里WRITE ^HIS.Patient报UNDEFINED。原因Caché 持久化类的默认全局变量名是^包名.类名DD 是 DataI 是 Index。比如HIS.Patient的数据在^HIS.PatientD索引在^HIS.PatientI。如果类里用了Storage定义自定义全局名又可能完全不一样。解决编译类后在 Studio 里右键类 - 查看存储或者用DO $SYSTEM.OBJ.GetClassStorage(HIS.Patient)看实际全局变量名。写代码引用全局变量前先确认存储定义。5.3 坑三$GET 和直接读取混用导致逻辑错误现象代码里写IF ^PAPER(PatientID,Name) 张三当这个患者没有 Name 字段时直接报UNDEFINED错误整个 routine 中断。原因直接读取不存在的全局变量节点会抛UNDEFINED而$GET会返回空串。很多老代码里混用两种写法导致偶发性报错。解决统一用$GET读取可能不存在的节点只在确定节点存在时用直接读取。如果确实需要区分“空串”和“不存在”用$DATA判断$DATA(^PAPER(PatientID,Name))返回 0 表示不存在1 表示有值10 表示有子节点。5.4 坑四版本升级后 $ZTRAP 行为变化现象从 Caché 2016 升级到 2018 后原来用$ZTRAP做错误处理的代码在部分场景下不再捕获错误事务没有回滚。原因Caché 2018 对$ZTRAP和TRY/CATCH的交互做了调整如果代码里同时用了$ZTRAP和TRY错误可能被TRY先捕获$ZTRAP不生效。解决新代码统一用TRY/CATCH老代码升级前先跑一遍错误场景测试。如果必须保留$ZTRAP确保没有嵌套TRY块。升级前在测试库用%UnitTest跑一遍关键业务路径。5.5 坑五ODBC 连接池耗尽导致接口超时现象HIS 和外部系统做接口对接跑了一段时间后接口开始超时重启应用服务器恢复过一阵又出现。原因JDBC/ODBC 连接没有正确关闭连接池被占满。Caché 的 SuperServer 默认最大连接数有限HIS 生产库通常设 200 到 500如果每个接口调用都新建连接不释放很快耗尽。解决用连接池管理连接确保Connection、Statement、ResultSet都在finally块里关闭。监控 Caché 管理门户里的System Connections页面看当前连接数和等待队列。如果连接数持续接近上限要么调大Maximum connections要么查代码里的连接泄漏。6. 用 %UnitTest 给 HIS 接口写回归测试一个可复用的验证套路HIS 系统的二开最怕改一个接口影响另一个接口尤其是医嘱、收费、库存这三个模块耦合紧密。我一般会在改动前先给相关接口写一组%UnitTest测试改完后跑一遍确认没有回归。Caché 自带的%UnitTest框架虽然不如 JUnit 生态丰富但足够覆盖 HIS 里的核心业务路径。先建一个测试类继承%UnitTest.TestCaseClass HIS.Test.AdmissionTest Extends %UnitTest.TestCase { /// 测试前准备清理测试数据 Method OnBeforeOneTest() As %Status { KILL ^HIS.AdmissionD(EP_TEST_001) QUIT $$$OK } /// 测试新增就诊记录 Method TestAddAdmission() As %Status { SET adm ##class(HIS.Admission).%New() SET adm.EpisodeID EP_TEST_001 SET adm.PatientID P_TEST SET adm.DeptCode D001 SET adm.AdmTime $ZDATETIME($HOROLOG,3) SET adm.AdmType 门诊 SET sc adm.%Save() DO $$$AssertStatusOK(sc, 保存就诊记录应成功) DO $$$AssertEquals($GET(^HIS.AdmissionD(EP_TEST_001)), P_TEST, 患者ID应匹配) QUIT $$$OK } /// 测试按患者查询 Method TestQueryByPatient() As %Status { SET rs ##class(%ResultSet).%New(HIS.Admission:ByPatient) DO rs.Execute(P_TEST) SET count 0 WHILE rs.Next() { SET count count 1 } DO $$$AssertTrue(count 0, 应查到至少一条就诊记录) QUIT $$$OK } }逻辑说明OnBeforeOneTest在每个测试方法前执行用来清理测试数据避免测试之间互相干扰。$$$AssertStatusOK和$$$AssertEquals是%UnitTest的断言宏失败时会在测试报告里标红。参数说明测试数据用EP_TEST_前缀和生产数据区分开跑完测试后统一清理。跑测试用终端命令; 执行测试类输出结果到当前设备 DO ##class(%UnitTest.Manager).RunTest(HIS.Test.AdmissionTest, /nodelete)参数说明第一个参数是测试类名第二个参数/nodelete表示跑完不删测试类。如果要在 CI 里跑加/outputjson输出 JSON 格式结果方便解析。这个套路的实用价值在于每次改 HIS 接口前先跑一遍现有测试确认基线是绿的改完再跑一遍红了就知道哪里被改坏了。我自己的习惯是任何涉及医嘱状态流转的改动必须配至少三个测试正常流转、异常回滚、并发锁冲突。这三个场景覆盖了 HIS 里 80% 的翻车情况。最后说一个血泪教训不要在测试类里连生产库跑。我见过有人在生产命名空间里跑%UnitTestOnBeforeOneTest里的KILL直接删了真实患者数据。测试类必须连独立的测试命名空间或者至少在OnBeforeOneTest里加环境判断非测试环境直接QUIT。希望帮到你。本文还有配套的精品资源点击获取