HIS系统迁移国产数据库后,高峰期卡顿的三大关键坑与调优实践
医院HIS系统迁上国产库,最典型的“虚假成功”就是:能跑。日常操作、医嘱录入、收费结算,看着都正常,系统可以上线,领导点头,信息科松一口气。可门诊高峰一来,9点到11点半,整个系统的请求像约定好一样涌到数据库门前,卡顿、超时、转圈,一层一层往外传——这个场景我太熟了。最近我复盘了一个HIS迁移项目的线上高并发调优,把根因一条条捋下来,发现“平时能跑、高峰就卡”背后,真正卡住系统的三个坑都在“迁移时想当然”上。这篇就把这三个坑拆开讲清楚,顺便聊聊高并发调优里那些常规文档不会写的事。
1. 先搞清楚前提:从“能跑”到“高峰不卡”中间隔着什么
1.1 这个项目到底在做什么
这里说的HIS,是医院信息系统里最核心的那套业务系统,覆盖门急诊挂号、医生工作站、收费记账、药房发药、住院医嘱、护士执行等等。这类系统的典型特征就是业务链路长、并发峰值集中、读多写也多,而且很多操作是事务性的,比如医生保存一组医嘱,可能要同时写主表、明细表、费用表、执行表,一个操作下去牵动好几个环节。
这次项目是把HIS从原有的商业数据库迁移到国产数据库。原有环境常见的是Java+Oracle,或者.NET+SQL Server,目标库一般就是达梦、人大金仓、openGBase、OceanBase这类的国产数据库。迁移手段通常是兼容模式加语法改写,把表结构、存储过程、触发器、应用里的SQL都搬过去,功能跑通后系统就切换上线了。
但功能跑通和扛得住真实业务压力,完全是两码事。HIS这种系统,平时可能几十上百个并发就够用,但门诊高峰时,光一个三甲医院的门诊收费窗口、自助机、医生站加起来,瞬间请求量可能上千甚至更高。这时候数据库层面的行为差异会成倍放大。
1.2 为什么“平时能跑”是个错觉
很多团队在迁移验收时做的测试,基本是“功能冒烟测试加低并发压测”,把几个典型业务走一遍,看结果对不对、响应时间是不是在正常范围。问题在于,低并发下数据库的优化器、锁机制、连接管理差异几乎不会暴露出来。就像一辆车在市区慢慢开,发动机好坏很难感受出来,上了高速满载超车时,动力和变速箱的匹配问题才明显。
HIS迁库也是一样。整体性能问题在低峰期被掩盖,到了高峰期并发数一上来,连接池被占满、事务锁等待拉长、慢SQL把资源吃光,这些问题会连锁反应,最终表现为门诊大厅系统转圈、护士站报错、医生开不了单。所以做这类项目,一定要把“迁移成功”拆成两个阶段来看:第一阶段是功能迁移,第二阶段是性能迁移。很多团队只做了第一阶段,就直接跳到上线,自然会在高峰期踩坑。
2. 坑一:连接池参数照搬老库,高峰全部堵在门口
2.1 症状:数据库没跑满,应用却在疯狂超时
先说说我复盘时遇到的第一个现象。系统刚切换完那几天,白天低峰时段一切正常,门诊人数一上来,应用日志开始大量出现“获取连接超时”的报错,前端接口响应时间从几百毫秒直接跳到十几秒。DBA去数据库那边一查,活会话数也不算高,顶多七八十个,数据库CPU也就40%上下,怎么看都不像是资源瓶颈。
这就很迷惑了。数据库有资源,应用却拿不到连接,问题肯定是出在连接池配置上。翻了一下配置文件,果然,连接池参数几乎是从老库环境原封不动搬过来的。老库当时是物理机,连接池maxActive配的是200,应用部署了四个节点,加起来最多占用800个数据库连接。老库默认能扛一两千个会话,所以从来没觉得连接是问题。
迁到国产库之后,数据库的连接数上限本身可能就被压低了,比如有的国产库默认max_connections只有500,或者单个会话占用的内存更高,DBA为了保险还给限量了。四节点应用如果还按maxActive=200配,高峰期一个节点瞬间需要几百个连接,四个节点加起来直接把数据库连接数打满,后面再来的请求就只能排队等你超时。
2.2 原理解析:连接池就是一个排队系统
连接池这东西,说穿了就是一个排队系统。应用启动时初始化一批连接放在池子里,业务线程要操作数据库时从池子里借一个,用完再还回去。池子的大小决定了最多能有多少个业务线程同时拿到数据库连接,池子满了以后,新的请求只能等别人还连接,等不到就抛超时。
这里有个常见误区:很多人以为连接池越大越好,能扛住更高的并发。其实不是。连接池大小不能只看应用侧的并发线程数,还得看数据库能承受多少活跃会话。每个数据库连接在数据库端都会占用内存,有的国产库还会为每个连接分配线程栈、解析上下文等资源,连接数越多,单连接的资源成本就越高。
更重要的是,连接池的大小应该和应用的业务类型匹配。像HIS这种OLTP系统,大多数操作都是短事务,一个操作通常几十毫秒到几百毫秒,连接持有时间不长,连接池不需要配得特别大。但如果应用里有大量长事务,比如一个事务里循环插入几百条数据,连接持有时间就会变长,高峰期每个连接被占用的时间也长了,池子自然更容易被打满。所以在调连接池之前,得先确认事务和SQL本身没有拖后腿,否则调大连接池只是掩盖问题。
2.3 怎么算:一个可落地的连接数估算过程
我这次复盘时,是用一个很朴素的方法来定连接池上限的。先看数据库侧的最大连接数限制,给日常运维留出余量,比如数据库max_connections设成500,那应用侧所有实例加起来最多占400个连接,剩下100个留给DBA坐席、监控工具和运维操作。
然后再看应用侧有多少个实例节点。假设应用部署四个节点,那每个节点的连接池上限就是400除以4,100个。这个100不是随便拍的,是对照门诊高峰期的实际并发请求量算出来的。比如高峰期同时在线操作大概500个请求,每个请求在数据库端的平均执行时间是100毫秒,那系统每秒大概能处理5000个请求,理论上100个连接就够用了,如果每个操作平均要200毫秒,那100个连接每秒只能处理500个请求,确实会排队。算完之后再结合压测适当调整,最终把maxActive定在一个“高峰期够用,但不会把数据库连接数打满”的范围。
这里还要强调一下maxWait这个参数。它是获取连接的等待时间,单位毫秒。很多人只调maxActive,忘了调maxWait,结果池子一满,应用线程全部卡在等连接上,超时时间又设置得太长,导致前端请求堆积,最后整个应用无响应。我一般会把maxWait设成3000到5000毫秒,既不会太快失败,也不会让线程无限等下去。
2.4 实操配置:Druid连接池的参数调整记录
我们这边应用用的是Java技术栈,连接池用的是Druid。调整后的核心配置大概是这样的,我贴出来给大家参考:
几个参数的作用分别说一下。initial-size和min-idle是初始化时预建的连接数,我设成20,是为了避免刚启动时业务请求到了还要现建连接。max-active是池子的最大连接数,这里设成了100,四个节点加起来400,留了余量。max-wait是获取连接的最大等待时间,5000毫秒。time-between-eviction-runs-millis是每隔60秒检查一次空闲连接,min-evictable-idle-time-millis是空闲超过5分钟的连接会被回收。
test-while-idle我设成true,会在空闲检查时用validation-query验证连接是否可用,这样能提前发现被数据库踢掉的死连接。test-on-borrow和test-on-return都设成false,每次借连接和还连接不做额外校验,避免多一次网络往返,这对高峰期性能有明显帮助。
remove-abandoned这三个参数是防连接泄漏的。之前出现过一次事故,应用代码里有个地方没在finally里关闭连接,高峰期连接池被泄漏的连接占满。开启remove-abandoned后,超过180秒未归还的连接会被强制回收,虽然不如从代码上根治,但至少能兜底,避免连接池被慢慢“漏”光。
2.5 避坑提示:别把连接池调成数据库杀手
连接池参数调整里最关键的一条经验是:所有实例的连接池上限加起来,一定要低于数据库的max_connections。你按单节点去调,好像每个节点都很合理,但多节点一叠加,可能直接就把数据库连接数打满了。
另外,连接池不是越大越好。连接数超过数据库的连接处理能力后,多出来的连接其实是资源浪费,还会增加数据库的调度压力。比如有些国产库是按照连接数分配内存的,连接数太多会导致数据库端OOM。我见过一个项目,DBA把max_connections调到了2000,结果数据库进程内存涨到几十GB,系统反而越来越慢。这就是典型的没有管住连接池,让连接数失控了。
还有个容易被忽略的点:配置改完之后,一定要做连接数上限的回归验证。我在这次项目里是直接用压测模拟门诊高峰,同时在数据库端查询当前活跃连接数,看是否接近上限。如果活跃连接数经常顶到上限附近,说明要么连接池配大了,要么SQL执行时间太长导致连接持有时间过长,这时候要回头查SQL和事务,而不是继续加连接池。
3. 坑二:事务边界和批量提交方式,把锁住的时间拉长了
3.1 现场还原:CPU不高、会话不高,应用就是卡
连接池的问题处理完之后,系统确实好了一阵子。但到了下一个门诊高峰,又出现了另一种卡法:数据库CPU只有30%,活跃会话数也不高,可业务接口的响应时间还是居高不下。这就不是连接满了,而是请求在数据库端执行时被堵住了。
我抓了一批应用线程dump,发现很多线程都卡在JDBC执行SQL的调用上。再到数据库侧查锁等待视图,一瞬间就找到了元凶:大量会话在等待行锁,而且等待时间动辄好几秒。简单来说,业务操作没崩溃、没报错,但大家都在排队等着拿同一批数据行的锁,谁也别想往前走。
具体场景是门诊医生保存医嘱。一个保存动作,逻辑上要把医嘱主表、医嘱明细表、费用表、执行表全部更新一遍,涉及的行数从几条到几十条不等。高峰期几十个医生同时在保存医嘱,尤其是几个医生同时管同一批患者或者同一个科室的共用药品数据时,很容易打到同一行或同一个索引区间上,行锁竞争一下子就激烈了。
3.2 根因拆解:为什么迁到国产库后锁问题变严重了
这里面的原因有两个层面。第一是应用本身的事务设计不够好,第二是国产数据库和老数据库在锁机制、并发控制上的行为有差异。
先看应用层。很多HIS系统是从早期.NET+SQL Server那套架构一路演化过来的,代码里常见一个事务方法里干很多事情,比如保存医嘱时,先insert主表,然后循环明细列表,一条一条insert明细数据,再update费用表,中间可能还夹杂着调外部接口、发送消息、写日志之类的逻辑。一个事务里串了这么多操作,整个事务的执行时间可能长达几百毫秒,甚至几秒钟。
事务时间越长,锁持有的时间就越长,其他想访问相同行的事务就得等更久。这在低并发下几乎没感觉,因为冲突的概率低,但高峰期并发一上去,锁等待时间会被等比例放大,最终从偶发卡顿变成常态卡顿。
再看数据库层面的差异。像Oracle这种老牌商业库,多版本读一致性做得非常好,读操作在绝大多数情况下不会被写阻塞,写和写之间也有比较精细的锁管理。但不少国产数据库在并发写场景下,行锁的粒度、锁等待的处理方式跟Oracle并不完全一样,有些库的锁竞争激烈时,甚至连读操作都会受到写事务的影响,或者同一个事务里后续的读语句也拿不到快照,只能等写锁释放。
这就导致同样的应用代码,在Oracle上能撑住的高并发,搬到国产库上就提前撞上了锁瓶颈。再加上HIS系统里大量操作都是同一个患者、同一份医嘱的读写,热点行比较集中,锁冲突更容易被触发。
3.3 优化实操:批量提交和收窄事务边界
锁等待的问题,不能只靠DBA在数据库端调参数,应用侧的写法必须跟着改。我复盘后做了三件事。
第一件事是改批量提交。原代码里保存医嘱明细是这样的伪代码:
这样每一条明细都要跟数据库交互一次,假设一个医嘱有20条明细,光insert就要发20次SQL,每次往返加上事务提交的开销,时间全耗在路上了。改成JDBC的批量提交后,20条明细合并成一次网络交互交给数据库,执行时间直接砍掉一半以上。代码大致是这样:
批量提交最直接的收益是减少了网络往返次数,尤其是明细多的时候效果非常明显。但同时要注意,batchUpdate只是把多条SQL打包发送,并不是把它们放在同一个事务里自动提交,所以事务边界还是得自己在代码里控制好。
第二件事是收窄事务边界。原来的保存医嘱方法,把整个保存流程放到一个大事务里,包括查库存、调外部接口、写日志这些跟业务主链路无关的操作。我把这些非核心操作踢出事务,只让写医嘱主表、明细表、费用表这几步留在事务里。原来一个事务可能要跑800毫秒,改完之后事务内的核心操作压缩到150毫秒左右,锁持有时间自然就下来了。
第三件事是检查数据库端的锁等待超时和事务隔离级别。国产库里有个参数,控制事务等待行锁的最大时间,比如MySQL系的innodb_lock_wait_timeout,默认50秒。这个时间如果太长,前端用户会以为系统死了,实际上事务只是在等锁。我把锁等待超时调到5秒,配合应用侧的事务超时控制,高峰期出现锁等待时能快速失败并让用户重试,而不是所有请求堵在数据库里不动。
3.4 别把锅都甩给JVM:GC只是表象
这类高并发调优里,还有个很容易被带偏的方向——一卡就想调JVM。我遇到好几个人一上来就怀疑是不是GC太频繁、堆内存不够,整天盯着JVM监控看。实际上,HIS这种业务系统,大多数情况下GC不是根因,而是受害方。正是因为数据库锁等待拉长了请求响应时间,业务线程长期挂在数据库调用上,大量对象在堆里滞留,GC频率才会升高。
我排查时用Arthas的trace命令看了几个卡顿线程的调用链,发现绝大多数时间都花在JDBC execute和数据库等待上,JVM自身反而没什么压力。这时候去调堆大小、换垃圾回收器,方向是错的。正确做法是先确认应用线程被什么堵住了——锁等待、慢SQL、连接池排队、还是外部接口调用,定位到真正的瓶颈再对症下药。
4. 坑三:SQL还留着老数据库的“使用习惯”,执行计划就歪了
4.1 症状:某几条SQL高峰期执行时长暴涨,连接池被拖垮
锁问题解决后,系统已经能平稳度过普通日子的门诊高峰了。但真正让我意识到这事还没完的,是后来一次全链路监控里发现的诡异现象:有两条SQL,平峰期执行只要50毫秒,一到高峰期就变成5秒、8秒,甚至更久。这两条SQL在慢日志里反复出现,高峰期每秒能执行几百次,直接把数据库资源拖垮。
遇到这种执行时间波动巨大的SQL,第一反应不应该是加索引,而是先看执行计划。把SQL在高峰期重新EXPLAIN一遍,我发现了关键线索:两条SQL的核心查询条件里都带了OR,或者对字段做了函数处理,导致数据库优化器放弃了索引扫描,改走了全表扫描。一旦走了全表扫描,数据量一大,执行时间就是指数级往上涨。
4.2 为什么同一个SQL,在老库没事、新库就歪了
不同数据库的优化器实现差异非常大。老商业库的优化器积累了二十多年的经验,对很多“不标准”的写法有内置的改写能力。比如Oracle的查询重写非常激进,你写的SQL不规范,它可以自动改写成更优的形式;SQL Server的优化器在某些场景下也能自己调整。
但不少国产数据库的优化器还在成长期,对复杂写法的自动改写能力不如老商业库。换句话说,同样的SQL,在老库上虽然写得绕,但优化器帮你兜底了;搬到国产库上,优化器没兜住,执行计划直接跑偏。这就引出一个迁移项目里的关键经验:迁库不是把SQL搬过去就行,得把SQL的执行计划都重新过一遍。
我在这个项目里踩到的一个典型场景是:一条查询里用了OR,比如“查这个患者在某段时间内所有已执行或未执行的医嘱”。原本两个条件各自都能走索引,但OR写在一起后,优化器没法同时利用两个区间,干脆全扫。改成UNION ALL把两种状态拆开查,各自走各自索引,再合并结果,执行时间一下子从800毫秒降到不到10毫秒。
4.3 几个真实改写的案例记录
我把这次复盘里改掉的SQL类型整理了一下,主要就三类。
第一类是OR改UNION ALL。刚才说的那个查询就是典型。还有一种是查患者同时有多个条件,比如“出院患者且做过特定检查”,用OR连接后优化器判断成本较高就全扫了。改成UNION ALL后,每个分支都能走独立索引。
第二类是字段上带函数导致索引失效。比如查询条件写成这样:WHERE date_format(visit_time, '%Y-%m-%d') = '2024-11-20',这种写法等于让数据库在每一行上算一遍日期格式,索引完全用不上。改成范围查询:WHERE visit_time >= '2024-11-20 00:00:00' AND visit_time < '2024-11-21 00:00:00',既能走索引,逻辑上也更严谨。
第三类是隐式类型转换。比如字段本身是字符串类型,查询条件却传了数字,WHERE patient_card_no = 123456。数据库要先把字段转成数字才能比较,索引自然失效。这种情况在HIS系统里很常见,因为不少业务流程里患者卡号、医嘱编码都是从Excel导入或接口传过来的,类型不统一。排查方法很直接:看执行计划里有没有出现“CAST”之类的内部操作。
4.4 实操方法:慢日志、执行计划、统计信息一个都不能少
调优SQL首先要有一套能快速定位问题的手段。这次项目里我开了数据库的慢查询日志,阈值设成1秒,高峰期结束后把慢日志拉出来,按照执行次数和总耗时排序,优先处理那些“跑的次数多、单次也慢”的SQL。这里有个经验:单次慢但执行次数少的SQL,优先级反而低;一条SQL一天执行几千次,每次慢500毫秒,那才是真正需要优先处理的。
定位到慢SQL之后,用EXPLAIN看执行计划,重点看三列:type是不是ALL或者index,possible_keys有没有可用的索引,rows估算的行数是不是明显大于实际返回的行数。如果type是ALL,基本就是全表扫描,就该检查是不是索引没建,或者SQL写法导致索引失效。
还要提醒一句:统计信息这东西很容易被忽略。国产数据库的优化器生成执行计划时要参考统计信息,如果表数据量变化很大,但统计信息没更新,优化器会按旧的数据量估算成本,选错执行计划。迁移项目里尤其要注意,数据导完之后一定要重新收集统计信息。我在这个项目里吃过一次亏,一张表从几十万行变成两百万行,统计信息还是几十万行时的旧数据,优化器以为走索引只要扫几个块,实际一跑扫了一半表。
5. 高并发排查优先级和压测建议
5.1 排查速查表:先看哪一层,再动哪一层
这几个月复盘下来,我总结了一套排查优先级,每次遇到“高峰卡顿”的问题都按这个顺序来,能少走很多弯路。整理成表格方便大家对照参考:
| 排查优先级 | 检查项 | 常见表现 | 工具/手段 | 优先处理方向 |
|---|---|---|---|---|
| 1 | 连接池 | 应用报“获取连接超时”,数据库活跃连接数接近上限 | 应用日志、数据库连接数视图 | 核对连接池参数,综合多实例算总量 |
| 2 | 行锁等待 | 接口响应慢,但CPU和连接数都正常 | 数据库锁等待视图、事务等待视图 | 查长事务、批量提交、收窄事务边界 |
| 3 | 慢SQL | 慢日志持续刷出同几条SQL,执行计划异常 | 慢日志、EXPLAIN、统计信息更新 | 索引优化、SQL改写、重建统计信息 |
| 4 | 应用线程 | 线程dump大量线程卡在数据库调用或外部接口 | jstack、Arthas trace | 判断是数据库阻塞、外部依赖慢还是GC异常 |
| 5 | 机器资源 | CPU、内存、IO全满 | 系统监控、数据库性能视图 | 扩容或做更细的分库分表 |
这个顺序背后的逻辑是:先确认请求到底有没有到数据库,再确认到了数据库之后是被锁挡住了还是被SQL拖住了,最后才去看应用进程和机器资源。很多人一上来就查JVM、查服务器,绕了一大圈还没找到根因,就是因为没有按这条链路逐层下钻。
5.2 压测怎么做才贴近真实门诊高峰
迁库之后必须要做压测,但很多团队做的压测太“温柔”了,根本压不出问题。这次复盘给我的教训是:压测场景一定要还原门诊高峰的真实结构,不能只压几个简单的查询接口。
医院的业务特点是有明显的热点,比如号源、患者档案、常用药品字典,这些数据被大量请求集中访问。压测时要专门构造热点数据,让成千上万个虚拟用户去抢同一批数据。比如模拟100个医生同时给同一科室的患者保存医嘱,看数据库的锁等待是否失控;模拟大量请求同时查询同一个患者的历史记录,看索引和缓存是否能扛住。
压测的并发数也要成梯度往上加。比如从50并发起步,每5分钟加50,直到系统出现明显瓶颈。记录每个并发梯度下的响应时间、活跃连接数、锁等待次数、慢SQL数量,这样才能看到一个完整的拐点。这里的重点是:不是等到系统挂了才去看问题,而是在系统快挂之前提前抓到瓶颈信号。
5.3 复盘里最容易忽略的几个小事项
最后分享几个我这次项目里的事后总结。国产数据库不是“换一个底座就完事”,它跟老商业库在优化器成熟度、锁并发模型、连接资源管理上都有差异,迁库之后一定要把性能基线重新压一遍。我见过不少团队,迁库之后只把功能测试跑完就觉得大功告成,等业务高峰真来了才开始补作业,那几天是最煎熬的。
另一个容易被忽略的是存量SQL的全面梳理。迁移之前最好能把应用里涉及的所有SQL统一捞出来,缓慢日志、全量抓包或者应用侧打印SQL日志,整理成SQL清单,迁库后按清单逐条EXPLAIN。这个工作很枯燥,但非常值得,因为大部分性能问题都藏在那些平时没人注意、只在高峰期才暴露的SQL里。
还有一点,上线后的监控一定要比平时更密集。迁库切换后的第一个门诊高峰,最好安排DBA和应用开发一起现场盯监控,发现问题当场抓证据。有一次我在现场看到数据库锁等待飙升,立刻用SQL抓了正在等待锁的事务和持有锁的事务,顺着代码找到了一个没设置超时时间的批量任务,当场改成小批次提交,问题在半小时内就控制住了。这种现场调试的效率,比事后翻日志高十倍不止。