☰
Caché HIS数据库开发实战:Global模型、事务锁与索引排障
2026/10/27 10:59:44 网站建设 项目流程

简介:这份面向医疗HIS系统开发者的Caché数据库文档合集,围绕高性能、高可用与复杂医疗数据建模等核心需求,帮助读者系统掌握Caché在HIS环境中的安装配置、数据模型设计、应用开发与运维管理。压缩包共145个文件,以143份PDF文档为主,另含1个HTML索引页和1张JPG示意图,整体约77.18MB,目录结构便于按主题检索与离线查阅。内容覆盖基础教程、MUMPS编程语法与开发实践、API参考、高可用及灾难恢复策略、用户权限与数据安全管理,以及报表分析工具使用说明;同时涉及Web服务集成、Caché Studio调试与性能监控、医疗图像和患者病历等复杂结构数据的处理,并配有示例项目辅助快速上手。已有1034人学习,适合希望从入门到进阶理解Caché数据库、优化HIS系统性能与可靠性的开发者参考,并兼顾医疗行业实践。

1. 从挂号窗口卡住的那三秒说起

门诊高峰,挂号窗口排到走廊尽头,收费员敲一次回车要等三四秒才出下一个界面。追这条链路时很多人第一反应是应用服务器扛不住,实际上问题经常落在 Caché HIS 数据库的索引和锁上。这份资料包围绕的就是这一层:一套面向医疗 HIS 场景的 Caché 数据库开发文档,从 Global 多维稀疏数组的数据模型、ObjectScript 类定义,到命名空间与数据库映射、事务锁控制、Journal 与备份归档,覆盖的是这套多模型数据库在 HIS 系统里真正每天会被踩到的部分。适合三类人:接手老 HIS 维护、需要看懂存量 M 代码的后端;给 HIS 做接口和报表、要写 Caché SQL 的开发者;以及准备在存量库上做增量改造、又不敢乱动老结构的架构同学。

2. 技术选型与资料结构:HIS 为什么把 Caché 放在核心库位置

医院信息系统的数据有几条硬特征:单表数据量不算夸张,但访问模式极度碎片化;门诊挂号、医嘱、收费、检验申请之间是强关联写入;高峰期并发集中在少数几个热点上;而且系统一旦上线,停机窗口基本等于零。这类负载用关系库当然能做,但代价是大量的关联查询和索引维护。Caché 的取巧之处在于它把数据存成多维稀疏数组(Global),一个患者的所有字段就是一棵树上的若干节点,读一个患者是一次下标跳转,不需要 join。选型上的第二个原因是历史包袱:大量存量 HIS 的核心业务逻辑写在 M 例程里,这些例程直接操作 Global,换库等于重写业务,成本无法接受。

2.1 资料包的组织方式与推荐阅读顺序

拆这类文档包,我一般先看目录分层,因为它直接反映了作者理解的系统边界。按常见的组织方式,一套完整的 Caché HIS 开发资料大致是这么几层,每层解决一个特定问题,不要跳着看:

cache-his-dev-docs/ ├── 01-环境与架构/ # 实例安装、命名空间规划、数据库文件落盘位置 ├── 02-数据模型/ # Global 下标设计规范、患者主索引、字典表约定 ├── 03-ObjectScript/ # 类定义、持久化类、方法与例程编写规范 ├── 04-SQL与接口/ # 表名映射、视图、CSP 页面与对外接口约定 ├── 05-运维与排障/ # Journal、备份、锁冲突、性能诊断 └── 06-附录/ # 字段字典、错误码、常用命令速查

第一层是地基,讲清楚实例怎么装、命名空间怎么切、物理数据库文件放在哪个盘,后面所有内容都建立在这个映射关系上。第二层是这套库真正的核心,Global 的下标顺序一旦定错,后面加索引、写 SQL、做归档全是连锁反应。第三层是把 M 时代的写法收敛到面向对象的类上,新代码不应该再裸写 Global。第四层解决对外交付,HIS 总要给检验、影像、医保这些系统供数据。第五层最容易被跳过,但出事的时候全在这里。

阅读顺序上我不会按目录从头读到尾。更有效的走法是把 01 和 02 精读,03 挑持久化类和事务两节细看,04 按你实际要交付的接口类型选读,05 至少过一遍 Journal 和锁冲突两段,剩下的当手册查。文档里凡是带「约定」「规范」「禁止」字样的段落,通常就是前人踩过坑之后补上去的,优先级最高。

2.2 Global 数据模型:多维稀疏数组怎么装下整个门诊

Global 的本质是一个持久化的、按下标自动排序的稀疏多维数组。稀疏这点很关键:你给 100 号患者存了 Name,给 101 号只存了 Birth,中间那些没赋值的节点根本不占空间,也不需要预先定义表结构。对 HIS 这种字段差异极大的场景(自费患者和医保患者的字段能差出一半),这比关系表的空列友好得多。

下标设计是这套模型里唯一真正需要动脑子的地方。核心原则只有一条:把查询条件按命中率从高到低、按范围从粗到细排进下标,让常用查询变成一次前缀跳转。

; 患者主索引:第一层是患者 ID,第二层是字段名 ; 这种"ID + 字段"的结构是 HIS 里最常见的字典式存法 Set ^HIS.Patient(1001,"Name") = "张三" Set ^HIS.Patient(1001,"Sex") = "M" Set ^HIS.Patient(1001,"Birth") = 19800101 ; 门诊挂号:院区 ^ 日期 ^ 科室 ^ 流水号 ; 日期放第二层,是因为按日结算、按日对账是最高频的批量查询 Set ^HIS.ADM("MAIN",20240521,"CARD",1) = "1001^张医生^挂号" ; 判断节点状态:10 表示有值无子节点,11 表示有值且有子节点 Write $Data(^HIS.Patient(1001)),! ; 11 Write $Data(^HIS.Patient(9999)),! ; 0 ; 遍历所有患者 ID:$Order 按下标排序返回下一个 Set pid = "" For { Set pid = $Order(^HIS.Patient(pid)) Quit:pid="" Write pid, " -> ", $Get(^HIS.Patient(pid,"Name"),"(无名)"), ! }

这段代码里几个点值得展开。$Data返回的不是布尔值而是四种状态,10 和 11 的区别在于该节点下还有没有子节点,写删除逻辑时用它判断是删一个字段还是删整棵子树。$Order是遍历 Global 的唯一正确姿势,它按 Caché 的排序规则返回下一个存在的下标,跳着取、不用管空洞,效率跟数据密度基本无关。$Get带默认值这个用法要养成习惯,直接读一个不存在的节点在某些配置下会抛错误,而 HIS 里字段缺省是常态。

$Order的排序规则需要提前知道:数字下标排在前面且按数值大小排,字符串下标排在后面按字符序排。^HIS.ADM那个例子里,日期用数字20240521而不是"2024-05-21",就是为了让按日期区间的遍历保持数值序。如果写成带横杠的字符串,排序结果会变成纯字典序,跨年查询时容易出玄学问题。

2.3 三种写法并存:裸 Global、持久化类、SQL 视图

存量 HIS 里这三样东西是共存的,而且经常在同一个业务模块里出现,理解它们之间的映射关系比学会任何单一写法都重要。

最老的一层是直接操作 Global 的 M 例程,特点是快、直接、没有任何约束,字段名靠注释维护。中间一层是%Persistent持久化类,属性、索引、约束都在类定义里声明,编译时自动生成对应的 Global 存储结构和索引维护代码。最外一层是 SQL,Caché 把持久化类映射成表,包名变成 schema,类名变成表名,中间的点换成下划线。

Class HIS.Registration.Patient Extends %Persistent { /// 患者主索引号,跨系统唯一 Property PatientNo As %String(MAXLEN = 20, MINLEN = 1) [ Required ]; Property Name As %String(MAXLEN = 50) [ Required ]; /// 性别用值列表约束,存 M/F,界面显示中文 Property Sex As %String(DISPLAYLIST = ",男,女", VALUELIST = ",M,F"); Property BirthDate As %Date; /// 建档时间由表达式兜底,插入时不用业务代码赋值 Property CreateTS As %TimeStamp [ InitialExpression = {$ZDateTime($Horolog,3)} ]; /// 患者号唯一索引,防止并发建档产生重复主索引 Index PatientNoIdx On PatientNo [ Unique ]; }

这个类编译之后,默认会落到^HIS.Registration.PatientD这个 Global 上,索引落到同名前缀的I节点里。VALUELIST和DISPLAYLIST是 Caché 里很实用的一个设计:数据库里存英文编码M/F,界面和报表上显示中文,编码和展示分离但只维护一份定义。InitialExpression里的$ZDateTime($Horolog,3)返回形如2024-05-21 10:30:00的标准时间戳,比在每个插入方法里手写赋值可靠。

同一份数据用 SQL 访问是这样:

-- 类 HIS.Registration.Patient 映射为表 HIS_Registration.Patient -- 按姓氏前缀查,%STARTSWITH 能命中索引,不要用 LIKE '%张%' SELECT PatientNo, Name, BirthDate FROM HIS_Registration.Patient WHERE Name %STARTSWITH '张' ORDER BY PatientNo

%STARTSWITH是 Caché SQL 里少数几个能走索引的前缀匹配写法,换成前置通配的LIKE就退化成全表扫描,几万条数据时感觉不出来,几十万条时报表就跑不完了。这个差别在文档的 SQL 章节里通常会单独标出来,值得记一笔。

3. 环境跑起来:命名空间、数据库映射与第一条 Global

文档看得再明白,不在本地跑一个实例都是纸上谈兵。Caché 的安装包不算大,单机装一个开发实例,半小时能到可以写代码的状态。这一章按「装完 → 切命名空间 → 读写 Global → 用 SQL 对齐」四步走,每一步都给可执行命令和验证方式。

3.1 命名空间与数据库映射:一个实例里隔出开发和生产

命名空间是 Caché 里最容易被新手忽略、但出事最多的概念。它不是 schema,也不是用户,而是「一份代码 + 一组 Global 物理数据库文件」的绑定关系。你可以有HIS、HIS-TEST、HIS-REPORT三个命名空间跑在同一个实例上,互相看不到对方的数据。开发环境不规划命名空间,测试数据写进生产库几乎是必然的。

装完之后先确认实例状态,然后用指定命名空间进终端:

# 查看当前机器上的实例列表与运行状态 ccontrol list # 启动实例(实例名在安装时确定,这里用占位) ccontrol start <实例名> # 以 HIS 命名空间进入终端 csession <实例名> -U HIS # 如果已经进到终端里,随时可以用 ZN 切换命名空间 # ZN "HIS-TEST"

-U参数指定的是登录后的默认命名空间,这一点跟关系库的「库名」用法接近,但语义更宽:命名空间还决定了你的例程(routine)从哪个物理目录加载。同一段 M 代码在两个命名空间里跑,读到的可能是完全不同的数据。

数据库文件的落盘位置和命名空间的映射关系在管理门户里配置,路径大致是系统管理 → 配置 → 系统配置 → 命名空间 / 本地数据库。这里有两个实操建议:一是物理数据库文件不要和 Journal 放同一个盘,Journal 的写入是连续的,跟数据文件的随机读写抢 IO 会明显拖慢事务提交;二是给报表和接口单独建命名空间,把只读访问和业务写入隔开,接口那边万一写了慢查询,也不至于把挂号链路一起拖死。

提示:新建命名空间时默认会问「是否复制已有命名空间的映射」,选是能省掉大量手工配置,但也容易把测试库的映射一起复制过去。建完之后务必逐个核对 Global 映射指向的物理库路径。

3.2 Terminal 实操:从一条 Set 到 ZWRITE 的完整验证

进到终端之后,第一个要跑通的不是 Hello World,而是「写一条 Global、读回来、看到它的真实结构」。这条链路通了,说明命名空间、数据库映射、写权限三件事都是对的。

; 切到目标命名空间 ZN "HIS" ; 写一条患者记录,故意只写两个字段,制造稀疏场景 Set ^HIS.Patient(2001,"Name") = "李四" Set ^HIS.Patient(2001,"Sex") = "F" ; 读回来,不存在时给默认值 Write $Get(^HIS.Patient(2001,"Name"),"未建档"),! Write $Get(^HIS.Patient(2001,"Phone"),"未登记"),! ; ZWRITE 会把整棵子树按可重新执行的格式打出来 ZWrite ^HIS.Patient(2001) ; 看节点状态,确认是 11 还是 10 Write $Data(^HIS.Patient(2001)),!

ZWRITE是这套库里最有用的排查工具,没有之一。它输出的格式本身就是合法的 ObjectScript 赋值语句,你可以把它复制出来粘到另一个命名空间里直接执行,做数据搬运比写导脚本快得多。上面这段执行完会看到类似^HIS.Patient(2001,"Name")="李四"的两行输出,注意数字 2001 和字符串"Name"的区别:前者是数字下标,后者是字符串下标,两者在 Caché 里是两种不同的下标类型,排序和查找都分开处理。

导出导入 Global 用^%GO和^%GI这两个系统例程,进终端直接敲例程名就能进交互式菜单。这个操作在不同版本上的菜单项顺序略有差异,但核心都是「先选 Global 名,再选目标文件」。做数据迁移和造测试数据时,这套工具比任何第三方脚本都稳。

3.3 SQL 侧对齐:表名映射、索引与执行计划

类定义完成之后,SQL 侧能不能高效查到数据,取决于两件事:表名映射对不对,索引建没建对。前者通常不会错,后者经常错得悄无声息。

表名映射的规则是固定的:类HIS.Registration.Patient对应表HIS_Registration.Patient,包路径的每一段用下划线连起来,schema 是包名,表名是类名。用 SQL 查一张不存在的表时,先把类全名按这个规则翻译一遍再对。

索引这块有个坑值得单说:如果存量数据是通过裸 M 代码直接写进 Global 的,而对应的类上后来才加了索引,那么这批老数据的索引项根本没有被生成,SQL 查询查不到它们,但 Global 遍历能看到。判断方法是拿同一条患者号分别走 SQL 和走$Order查一次,结果不一致就是索引缺失。

; 重建指定类的全部索引,数据量大时务必分批、在业务低峰执行 Set sc = ##class(HIS.Registration.Patient).%BuildIndices() Write $System.Status.GetErrorText(sc),!

%BuildIndices()是%Persistent自带的方法,遍历全表重新生成索引项。不带参数是全量重建,也可以传入索引名数组只重建指定的几个。这个方法在几十万行以上时会长时间占用锁并产生大量 Journal,线上执行前一定要确认 Journal 空间和业务低峰期。

看执行计划的入口有两个:管理门户的 SQL 执行界面里勾选 Show Plan,或者调%SYSTEM.SQL的 Explain 方法拿到查询计划文本。计划里重点看两处:扫描方式是全表还是索引,以及有没有出现临时文件排序。前者对应没走索引,后者对应ORDER BY的字段跟索引顺序不一致。

4. 核心模块拆解:门诊挂号链路的类定义与事务控制

挂号是 HIS 里最适合拿来练手的模块:数据量适中,写入集中,并发场景真实,而且一旦出问题业务感知极其明显。这一章按「表结构 → 事务与锁 → 归档备份」三层拆,每层都给可运行的代码和失败时的判断依据。

4.1 患者主索引与挂号表的字段约定

患者主索引(EMPI)是整个 HIS 的根,挂号、医嘱、检验、收费全挂在它下面。主索引表的设计要点不是字段多少,而是「患者号从哪来、重复了怎么办」。常见做法是用一个独立的序号 Global 发号,患者号带业务前缀,类上用唯一索引兜底。

Class HIS.Registration.Adm Extends %Persistent { /// 患者号,外键指向 Patient.PatientNo Property PatientNo As %String(MAXLEN = 20) [ Required ]; /// 院区编码,多院区部署时的分区键 Property Campus As %String(MAXLEN = 10) [ Required ]; /// 就诊日期,用 %Date 存,SQL 侧可以直接做区间比较 Property AdmDate As %Date [ Required ]; Property DeptCode As %String(MAXLEN = 10) [ Required ]; Property DoctorCode As %String(MAXLEN = 20); Property Fee As %Decimal(SCALE = 2); /// 状态:R 已挂号 / C 已退号 / S 已结算 Property Status As %String(VALUELIST = ",R,C,S") [ InitialExpression = "R" ]; /// 复合索引:按院区+日期+科室查当日号源,是最热的查询 Index CampusDateDeptIdx On (Campus, AdmDate, DeptCode); /// 按患者号反查就诊历史 Index PatientIdx On PatientNo; }

索引顺序(Campus, AdmDate, DeptCode)不是随便排的。查询条件通常是「某院区某天某科室」,三个字段都是等值匹配,顺序影响不大;但如果是「某院区某天全部科室」,前两个字段就能用上,第三个用不上,这时候索引依然有效。反过来把DeptCode放第一位,第二种查询就退化成扫描。这个取舍在文档的索引规范章节里一般会写,没有的话自己按实际查询条件倒推一遍。

%Decimal(SCALE = 2)存金额,不要用%Double。金额用浮点在结算对账时会出现分位误差,HIS 里这种误差会被放大成对不上账的事故,属于血泪经验级别的约定。

4.2 事务与锁:TSTART/TCOMMIT 的边界该划在哪

Caché 的事务模型跟关系库有个关键差异:它支持嵌套事务,内层的TCOMMIT不会真正提交,只是把嵌套层级降一级,真正的提交发生在最外层。这意味着你在业务方法里随手包的TSTART,很可能根本没起到你以为的作用——外层如果还有一层事务没结束,数据就一直挂在内存里没落盘。

ClassMethod Register(pPatientNo As %String, pCampus As %String, pDept As %String, pFee As %Decimal) As %Status { Set tSC = $$$OK ; 对患者行加增量锁,5 秒超时,防止同一患者并发建两条在途挂号 Lock +^HIS.Patient(pPatientNo):5 If '$Test { Quit $$$ERROR($$$GeneralError, "患者记录被占用,请稍后重试") } Try { TStart ; 取号:$Increment 是原子操作,多进程并发安全 Set tSeq = $Increment(^HIS.AdmSeq) Set ^HIS.ADM(pCampus, +$Horolog, pDept, tSeq) = pPatientNo_"^"_pFee TCommit } Catch ex { TRollback Set tSC = ex.AsStatus() } ; 锁必须成对释放,放在 Try 外面保证异常路径也能解 Lock -^HIS.Patient(pPatientNo) Quit tSC }

这段代码有几个参数和行为值得逐个说明。Lock +^HIS.Patient(pPatientNo):5里的:5是超时秒数,不写就是无限等待,高峰期一旦有进程卡住会把整条队列堵死。$Test是Lock之后立刻要检查的特殊变量,表示加锁是否成功。$Increment是这套库里唯一可靠的原子自增,用它取流水号,不要用「先读再写」的两步法,并发下必然重号。TStart/TCommit包住的只有写 Global 那两行,锁的加解都在事务外——这个顺序很重要,锁在事务里加、事务回滚时锁的行为跟你想的不一样。

Try/Catch里TRollback之后直接把异常转成%Status返回,不吞异常也不往上抛裸异常。HIS 的调用链通常很长,每层都抛裸异常最后会变成一堆看不懂的堆栈,统一用%Status回传能让调用方明确知道哪一步失败。

注意:$$$OK和$$$ERROR这两个宏定义在系统 include 文件里。新写的类如果编译报「宏未定义」,在类头补上#Include %occStatus和#Include %occErrors即可。

4.3 Journal、备份与归档:文档里最容易被跳过的一节

Journal 是 Caché 的预写日志,每个事务提交前先把变更写进去,实例异常宕机后靠它前滚恢复。它在管理门户里配置,路径通常在系统管理 → 配置 → 系统配置 → Journaling。配置项里最关键的是 Journal 文件所在目录和大小上限,默认配置下如果不设上限,一个大批量归档操作能瞬间把磁盘写满,实例会直接挂起。

备份策略常见的是两条腿走路:每日一次全量外部备份(实例级,通常在操作系统层做文件快照或调用备份例程),加上 Journal 的持续前滚。全量备份的时间点决定了恢复的起点,Journal 决定了能恢复到哪个时刻。文档里如果没有明确写 RPO 目标,就跟业务方确认一次「最多能接受丢多少分钟数据」,这个数字直接决定备份频率。

归档这块,HIS 的历史数据(超过一年的挂号、结算记录)不建议直接DELETE。常见做法是先按年切到独立的归档 Global,业务表只保留热数据,归档库通过视图或单独的命名空间提供查询。直接删的问题在于:大批量删除在一个事务里执行会生成海量 Journal 记录,等于把恢复能力和磁盘空间同时赌进去。分批提交、每批几千条、批间留间隔,是更稳的走法。

5. 避坑与排查:Caché HIS 开发里最容易翻车的五个地方

这一章的五条都是从实际故障里反推出来的,按「现象 → 原因 → 解决」写,可以直接拿去对号入座。

5.1 数据层面的三个坑

坑一:$Order遍历少数据,ZWRITE看到的和代码读到的对不上。

现象是同一棵 Global,用ZWRITE看有十几条,用$Order循环只取到几条。原因基本是下标类型混用:^HIS.Patient(1,"Name")和^HIS.Patient("1","Name")在 Caché 里是两个完全不同的节点,数字下标排在字符串下标前面,如果你的循环从""开始按字符串遍历,前面那批数字下标的节点就被漏掉了。解决方式是统一下标类型,在数据字典规范里明确写死「患者 ID 一律用字符串下标」或「一律用数字」,然后在写入侧做类型转换,不要指望读取侧兼容两种。

坑二:SQL 查不到老数据,Global 里明明有。

现象是某个患者用 SQL 按患者号查返回空,用$Order遍历能看到他的记录。原因是这批数据是存量 M 例程直接写进 Global 的,绕过了持久化类的索引维护逻辑,所以索引项根本没生成。判断方法是拿一条确定存在的记录对比两种查询结果。解决方式是执行%BuildIndices()重建索引,同时把写入路径统一收敛到类方法上,M 例程那边要么改造要么至少补一段索引维护代码。

坑三:金额字段对不上账,差几分钱。

现象是收费明细逐条加总和汇总表差几分。原因是金额用了%Double而不是%Decimal,浮点累加在分位上产生误差,加上四舍五入的时机不一致,误差被放大。解决方式是把所有金额字段改成%Decimal(SCALE = 2),并且约定「中间计算保留四位、最终落库时才舍入到两位」,不要在每一步都做舍入。

5.2 运行与运维层面的两个坑

坑四:TSTART嵌套之后数据没落库,重启才出现。

现象是某个业务方法明明执行成功、返回了成功状态,但另一个进程立刻去查却查不到,实例重启之后数据又出现了。原因是 Caché 的嵌套事务语义:内层TCOMMIT只是降级不提交,如果外层还有一个未结束的事务,数据一直在内存里。解决方式是统一事务边界,业务方法内部不主动开事务,由最外层的服务入口统一TStart/TCommit,中间层只写数据。同时在代码规范里禁止在循环体里开事务。

坑五:归档脚本一跑,Journal 目录爆盘,实例挂起。

现象是月底跑历史数据清理,跑到一半实例无响应,日志里全是磁盘写入失败。原因是大批量删除在一个事务里执行,每条删除都要往 Journal 写前镜像,几百万条记录的 Journal 体积能超过数据文件本身。解决方式是分批提交,每批控制在几千条,批与批之间留几十毫秒间隔让 Journal 有机会落盘切换;同时给 Journal 目录配独立的磁盘和容量告警,不要跟数据文件、备份文件挤在同一块盘上。

提示:这五条里,坑一和坑四属于「代码看着没错但行为不对」,排查时优先用ZWRITE看数据的真实形态,而不是盯着代码逻辑推。数据长什么样永远比代码写了什么更可信。

6. 二次开发:在存量库上做增量改造的几条走法

存量 HIS 的二次开发有个基本前提:你不能为了让新功能好写,去动老结构。老结构下面挂着十几年的报表、接口和对账脚本,改一个下标层级可能让三个月前的月报跑不出数。所以增量改造的核心思路是「加不改」——加字段、加索引、加新类,但不改已有下标的顺序和类型。

具体做法上,新字段一律往最深层加,不要插在中间。比如^HIS.ADM(Campus, Date, Dept, Seq)这层结构,新加一个「医保类型」就放到^HIS.ADM(Campus, Date, Dept, Seq, "InsType"),作为第五层子节点。这样所有按前四层遍历的老代码完全不受影响,新代码用$Get取第五层就行。反过来把InsType插到第二层,所有老遍历立刻全废。

新业务逻辑优先走持久化类,不要再写裸 Global 的例程。类上有索引、有约束、有 SQL 映射,报表和接口那边能直接用,省掉大量胶水代码。老例程和新类操作同一份数据时,索引维护是唯一需要额外注意的点,前面坑二已经说过。

灰度验证这块,我的习惯是给每个改造点配一个对比脚本,同一批输入分别走老链路和新链路,比对输出。这类脚本不追求优雅,追求能跑、能批量、结果能直接 diff:

; 对比新旧两条链路对同一患者的挂号结果 ; 返回 1 表示一致,0 表示有差异,差异明细打到终端 ClassMethod VerifyAdm(pPatientNo As %String, pDate As %Integer) As %Boolean { Set tOld = ..GetAdmLegacy(pPatientNo, pDate) ; 走老例程 Set tNew = ..GetAdmByClass(pPatientNo, pDate) ; 走新持久化类 If (tOld = tNew) Quit 1 Write "DIFF patient=", pPatientNo, " old=", tOld, " new=", tNew, ! Quit 0 }

这个脚本的价值在于它把「改造有没有改变行为」变成一个可以批量跑出结论的问题,而不是靠上线后盯监控。跑的时候抽三类样本:正常数据、边界数据(跨天挂号、退号重挂)、历史脏数据,第三类最容易暴露问题。

改造点、风险和验证方式我一般列成一张表贴在设计文档里,评审时逐个对:

改造点主要风险验证方式
新增字段(第五层子节点)老遍历代码误判节点状态$Data返回值对比,确认从 10 变 11 不影响判断
新增索引存量数据索引缺失%BuildIndices后 SQL 与$Order结果比对
新类替代老例程事务边界不一致导致数据可见性差异并发场景下双写对比,观察是否有延迟可见
归档表拆分Journal 体积暴涨分批提交压测,观察 Journal 切换频率

最后一条是我自己的习惯:任何一次动到 Global 结构的改造,上线前我都强制走一遍「导出 - 在测试命名空间导入 - 用老代码跑一轮回归」。这个动作看起来笨,但它能在十分钟内告诉你老代码会不会因为新节点而崩,比任何静态分析都直接。从那以后我每次改下标结构,不管多小,都强制走一遍导出导入加老链路回归,没再翻过车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询