上周接了一个医疗信息化相关的活,需求读完我第一反应是:这不是普通的诊所管理系统,而是一套Java云门诊HIS信息管理系统,交付模式是SaaS多租户,租户下面还要带多门诊。翻译成人话就是:同一套源码部署在云上,不同机构各自入驻、数据互不可见;同一家机构下面有多个门诊部,每个门诊又要能独立排班、独立收费、独立汇总业绩。很多做传统HIS的朋友觉得这就是“加个租户ID字段”的事,真做起来才发现,从数据隔离、机构建模、号源并发到打印模板,每一层都要重新设计。这篇文章把我在这个项目里的核心设计和踩坑过程写下来,重点是方案取舍和可落地的实现细节,给正在做SaaS化医疗系统的团队一个参考。
1. 为什么门诊系统要“上云转SaaS”:传统部署留下的三个口子
1.1 一门诊一套系统的运维成本
传统门诊HIS大多是私有化部署,一个门诊部一台服务器、一套数据库、一个独立的Tomcat实例。听起来没什么问题,可一旦客户手里有几十个门诊,情况就变味了。每次迭代发一个新版本,不是发一次,而是发几十次;数据库升级脚本要在不同环境执行,有一个节点忘了执行,就会出现某个门诊的报表数据对不上。我们当时的运维同学最怕的就是“上线日”,连夜挨个环境打补丁,凌晨三点还在群里问“有没有人漏跑了SQL脚本”,这绝对不是段子。
而且私有化部署意味着每个门诊要独立买服务器、独立做备份、独立维护网络。中小型门诊根本养不起专职运维,出了问题只能远程传给总部,总部工程师再逐个排查。时间全耗在环境差异上了,根本没精力做业务优化。
1.2 数据孤岛带来的业务盲区
部署分散带来的另一个大问题是数据孤岛。各门诊的挂号量、收费金额、药品库存全在自己的库里,总部想看一个整体经营报表,得让每个门诊导出Excel再手工合并。合并过程里还经常出现上午和下午的口径不一致,同一个“门诊收入”在不同门店定义不一样,导致管理层对经营情况的判断失真。
做SaaS化改造以后,所有门诊数据集中在同一套云环境里,按租户和门诊维度做数据隔离。总部的运营分析可以直接跨门诊汇总,也能按门诊下钻,不用再等Excel合并。这个需求几乎出现在每一个做连锁门诊的客户身上,所以“多门诊”不是可选项,而是基础能力。
1.3 云门诊SaaS到底改了哪些东西
把传统HIS改造成云门诊SaaS,不是把原来单机版代码拿过来加个登录页就完事。要改的核心是三件事:
- 数据隔离层:不同租户的数据必须严格分离,不能出现A机构的患者跑到B机构门诊挂号的情况。
- 业务模型层:租户下面要有门诊机构、科室、医生、号源、收费项目等多层主数据,业务单据必须绑定到具体门诊维度。
- 配置个性化层:不同门诊的收费标准、打印模板、导诊规则都有差异,不能靠改代码解决,必须做成租户级配置。
这三个口子不堵住,后面做多租户多门诊就是空中楼阁。我见过一些团队一上来就写业务功能,结果做到收费环节发现价格规则没法按门诊区分,只能退回重构,这是最伤士气的坑。
2. 多租户隔离方案选型:共享库+租户ID的前提与代价
2.1 三种主流隔离模式横向对比
多租户隔离有一个铁三角:隔离性、成本、运维便利性。三者只能取其二,不可能全占。我在设计一开始就把三种方案列了个表格,让团队讨论时有个统一坐标系:
| 隔离方案 | 数据隔离强度 | 部署与运维成本 | 适合场景 | 典型痛点 |
|---|---|---|---|---|
| 一租户一独立数据库 | 最强,物理隔离 | 最高,每租户一套库和连接配置 | 大客户、有合规强诉求的机构 | 跨租户汇总查询难,升级要批量执行 |
| 共享数据库+独立Schema | 较强,逻辑隔离 | 中等,连接共用一个实例 | 中型SaaS平台 | 连接数管理复杂,跨Schema查询受限 |
| 共享数据库+共享Schema+租户ID | 靠代码保证隔离 | 最低,单一Schema | 中小门诊、高并发平台 | 漏写租户条件就是数据事故,必须靠框架兜底 |
我们最终选了第三种:共享数据库、共享Schema、所有表带tenant_id。理由很简单:客户群体是连锁门诊,单体门诊的数据量不大,但租户数量多、单租户并发有限,物理隔离的资源浪费太严重。而且我们后续要做跨门诊经营分析,数据集中到一个Schema里,报表SQL写起来最顺畅。
2.2 我为什么没有选择“一租户一库”
有人可能会问,一租户一库隔离多彻底,为什么不用?我算过一笔账。假设每个租户一个独立数据库,连接池至少预留20个连接,50个租户就是1000个连接。数据库实例的max_connections通常是2000左右,看起来还能撑,但加上后台任务、报表查询、定时任务,连接数很容易触顶。更麻烦的是表结构升级,50个库要跑50遍DDL,一旦中间失败还得逐库核对,运维成本直线上升。
另外,一租户一库会严重拖垮跨租户的运营分析。总部想看50个门诊的收费趋势,如果用独立库方案,要么写多数据源聚合,要么把数据同步到数仓。对一个门诊HIS系统来说,这么做等于给自己上了枷锁。中小型SaaS系统,成本和迭代速度往往比“物理隔离”更重要。所以我把共享Schema作为默认选项,只有在客户明确要求独立部署时才切换到独立库模式,而且通过配置开关来实现,代码层尽量复用。
2.3 共享库模式下的租户上下文改造
共享库模式最大的风险是数据串租户。光靠开发人员写SQL时记得“where tenant_id = ?”是不靠谱的,必须要从框架层面强制注入。
我的做法是定义了一个TenantContext,用ThreadLocal保存当前请求的租户ID,在进入Controller之前拦截:
public class TenantContext { private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>(); public static void setTenantId(Long tenantId) { TENANT_HOLDER.set(tenantId); } public static Long getTenantId() { return TENANT_HOLDER.get(); } public static void clear() { TENANT_HOLDER.remove(); } }然后在Spring MVC的拦截器里解析请求头中的租户标识,写入TenantContext:
public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId = request.getHeader("X-Tenant-Id"); TenantContext.setTenantId(Long.valueOf(tenantId)); return true; } @Override public void afterCompletion(...) { TenantContext.clear(); } }这只解决了一层。真正要紧的是MyBatis查询库里那些不带租户条件的SQL。我们写了一个MyBatis拦截器,在Executor执行前自动改写SQL,把tenant_id条件拼接进去:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class TenantSqlInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 获取原始SQL,解析后加入 tenant_id 条件 // 这里可以用JSqlParser做SQL解析,或者只对约定的Mapper方法生效 return invocation.proceed(); } }这个自动拼条件的好处是,开发人员在写Mapper时不用每句都带tenant_id,减少漏写概率。但代价也很明显:SQL解析和重写需要专门的库支持,比如JSqlParser,而且动态拼接对复杂SQL会有性能开销。
这里我建议不要一刀切。我的实际做法是分层处理:核心业务表用拦截器强制注入,查询类报表和统计SQL单独走一个“显式传参”的Mapper接口。因为报表场景需要跨租户权限控制,不是简单的等于当前租户ID就能解决。
3. 多门诊模型设计:机构-院区-科室-职工怎么落成一张张表
3.1 组织主数据的分层:租户/门诊/科室
多租户搞定了,多门诊又是一个新维度。很多SaaS系统只有“租户”和“用户”两层,没有机构树的概念。但门诊HIS天然需要机构层级:租户是连锁集团,租户下面有多个门诊部,每个门诊部里又有科室,科室里有医生和护士。
我把组织主数据设计成一张organization表,用parent_id和org_type来区分层级:
CREATE TABLE organization ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, org_name VARCHAR(100) NOT NULL, org_type TINYINT NOT NULL COMMENT '1-集团 2-门诊 3-科室', parent_id BIGINT DEFAULT NULL, path VARCHAR(500) NOT NULL COMMENT '血缘路径,如 /1/2/15', sort_no INT DEFAULT 0, status TINYINT DEFAULT 1, created_at DATETIME, updated_at DATETIME, KEY idx_tenant_parent (tenant_id, parent_id), KEY idx_path (path) );path字段我用的是类似“/1/15/33”的物化路径方式,这样要查某个门诊下所有科室时,不用递归,一个like或者按前缀匹配就出来了。比如要查门诊ID=15下所有科室,条件就是path LIKE '/1/15/%'。
门诊和科室都是organization的子集,用org_type区分。挂号单、收费单等业务单据不需要单独冗余“集团”维度,只需要挂到具体的门诊ID或科室ID就行,因为通过organization表的血缘能回溯到租户。这样既灵活,又不损失查询性能。
3.2 诊疗业务表如何携带门诊维度
业务表设计上,我坚持一个原则:每张业务主表必须带doctor_id和clinic_id,也就是“谁开单、在哪个门诊发生”。比如一张收费单,如果只挂在tenant_id下面,就丢失了“是在哪个门诊收的”这个关键维度。跨门诊汇总的时候就会很尴尬。
典型表结构如下:
CREATE TABLE visit_record ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, clinic_id BIGINT NOT NULL COMMENT '门诊机构ID', patient_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, visit_no VARCHAR(32) NOT NULL, visit_time DATETIME NOT NULL, visit_status TINYINT COMMENT '1-待诊 2-就诊中 3-已完成', ... );从产品形态上,这叫“业务表携带组织维度,而不是依赖租户表推导”。因为同一患者在同一个租户下的不同门诊就诊时,如果业务表只记录tenant_id,那两次就诊很可能互相干扰。只有明确了clinic_id,门诊的队列、收费、库存统计才能独立运作。
3.3 号源与排班的模型设计
多门诊场景下,每个门诊各自排班,各自的号源池互不干扰。我把排班表设计成两层:排班计划和号源细粒度。
排班计划表示某医生在某门诊某段时间出诊,号源表则是排班计划的具体一个可挂号码:
CREATE TABLE schedule_plan ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, clinic_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, plan_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, total_slots INT NOT NULL, status TINYINT ); CREATE TABLE schedule_slot ( id BIGINT PRIMARY KEY, plan_id BIGINT NOT NULL, slot_time DATETIME NOT NULL, slot_no INT NOT NULL, patient_id BIGINT DEFAULT NULL, status TINYINT COMMENT '0-空闲 1-锁定 2-已挂号', version INT DEFAULT 0 );号源扣减靠乐观锁version字段,高并发下不会出现一个号被多人抢到。门诊维度体现在plan_id→schedule_plan通过clinic_id绑到具体门诊,号源表不需要再冗余clinic_id,查询时join schema即可。不过为了减少join,也可以把clinic_id冗余到schedule_slot里,看团队对性能的要求,我用的是冗余方案,因为挂号高峰期join会放大查询压力。
4. 从挂号到取药:门诊主业务链路的实现要点
4.1 号源池扣减与并发锁
门诊系统流程的起点是挂号。常规场景有现场挂号和预约挂号,无论哪种,落到技术层面都是“从号源池里选一个空闲号位,置为锁定状态”。如果号位只有一个,两个请求同时来,传统做法是select再update,但race condition会带来超卖。
我用Redis做分布式锁,每个号位对应一个锁key:
String lockKey = "schedule:plan:" + planId + ":slot:" + slotId; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!locked) { throw new BusinessException("号源正在被其他患者锁定,请稍后重试"); } try { int updated = scheduleSlotMapper.lockSlot(slotId, patientId); if (updated == 0) { throw new BusinessException("该号已被锁定或已挂出"); } // 继续创建挂号记录、收费单等 } finally { redisTemplate.delete(lockKey); }这里需要注意锁时间不能设得太短。我一开始设了5秒,结果有次系统在远程慢SQL上卡了4秒多,锁直接被释放,另一个请求进来把同一个号挂了。后来改成10秒,并且把锁的value设为唯一标识,删除锁之前先比对value,防止误删别人的锁。这种小坑不见一次真的很难记牢。
4.2 分诊队列的存储选型
挂号完成之后是分诊和候诊。早期的设计我直接用了数据库表poll_queue来模拟队列:患者挂号后插入一条waiting状态记录,分诊台点击“叫号”时更新状态。这套逻辑在小并发下没问题,一旦早高峰多个门诊同时操作,数据库的行锁冲突非常明显。
后来我把候诊队列搬到了Redis里,用List结构按门诊维度存储:
String queueKey = "clinic:queue:" + clinicId; redisTemplate.opsForList().rightPush(queueKey, visitNo); // 叫号时从左侧弹出 String currentVisitNo = redisTemplate.opsForList().leftPop(queueKey);用Redis的好处是消息即时可见,候诊大屏可以订阅对应的队列key做实时推送。但要注意,Redis里的队列和数据库里的挂号状态必须做一致性兜底。我的做法是:数据库里保留visit_status字段,Redis只负责“待叫号顺序”,叫号时先更新数据库状态,再执行pop。万一Redis宕机,还可以从数据库恢复队列,不至于瘫痪。
4.3 医嘱、处方、收费的一致性问题
门诊链路里最容易被忽略的一块是“医生开完处方到收费处结算”的时候。如果收费前药品价格变了,或者处方里的项目被停用了,怎么处理?
我采用“快照”策略:医生提交处方时,把开单时的项目名称、单价、数量全部快照进处方明细表。收费时只按快照金额计算,不再反查最新的价格目录。这样收费员和患者看到的金额,和医生开单时完全一致,不会出现“医生开单50元,收费却变成55元”这类纠纷。
当然快照策略的前提是所有收费项目变更走统一调价流程,价格调整后新处方立即用新价,老处方不受影响。这是门诊业务的基本要求,实现起来不复杂,但很多团队容易漏掉,觉得每次重新查询价格就行,结果上线后收到一堆投诉。
5. 一套代码适配所有门诊:配置中心与个性化策略
5.1 租户配置、门诊配置、全局配置的三级优先关系
SaaS系统最怕的是每个客户提一个定制需求,开发要跟着改一份代码。其实大多数“个性化”都可以收敛到配置层面。门诊HIS的个性化主要集中在挂号规则、收费标准、打印样式、医嘱模板、药品目录这些地方。
我在项目里建了一张配置表,用维度加优先级解决差异:
CREATE TABLE sys_config ( id BIGINT PRIMARY KEY, config_key VARCHAR(64) NOT NULL, config_value TEXT, scope_type TINYINT NOT NULL COMMENT '0-全局 1-租户 2-门诊', scope_id BIGINT DEFAULT NULL COMMENT '租户ID或门诊ID,结合scope_type使用', version INT DEFAULT 0, updated_at DATETIME );查询配置时按优先级排序:门诊配置 > 租户配置 > 全局配置。比如某连锁总公司规定所有门诊打印A4票据,但某个旗舰门诊要求用热敏纸,那么这个门诊只需配置一层print_template=thermal,其他门诊走上一级默认配置。
这套设计的核心改造成本在“配置覆盖链路”。如果每次取配置都现查数据库,性能会很差。我加了一个两级的本地缓存:一级缓存按scope维度缓存,二级缓存按config_key维度缓存,失效时主动刷新。这样配置读取的QPS压到能承受的范围内。
5.2 收费价格与折扣的差异化实现
门诊收费还有一个天然痛点:同一项“清创缝合”,不同门诊价格不同。有的按次收费,有的按伤口面积收费。这不能靠配置表简单搞定,因为计价公式都不一样。
我在收费项目表里增加计价模式字段:
CREATE TABLE charge_item ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, clinic_id BIGINT NOT NULL, item_code VARCHAR(32) NOT NULL, item_name VARCHAR(100) NOT NULL, billing_mode TINYINT COMMENT '1-固定价格 2-按数量 3-按区间 4-按公式', unit VARCHAR(16), default_price DECIMAL(10,2), formula_expr VARCHAR(200) COMMENT '计价公式,例如 price = base + extra * qty' );医生开单时,系统根据billing_mode和formula_expr动态计算金额。比如某门诊的“上门换药”是60元基础出诊费加每公里5元交通费,那formula_expr就是“60 + distance * 5”。这个方案让新门诊接入时不用改代码,只需要在界面上配置计价公式。
折扣规则也类似。但折扣要设计成可叠加的优先级链:患者折扣 > 活动折扣 > 门诊默认折扣。比如某集团做活动,所有项目打九折,但某个老患者有VIP卡可以再打八折,这两个要能叠加,而不是互相覆盖。
5.3 打印模板与报表的个性化
不同门诊打印单据说要求,比如A门诊的收费单要显示“已包含耗材费”,B门诊不需要。我用的模板方案是把所有单据模板做成FreeMarker或Velocity模板,存到配置中心里,而不是硬编码写在Java里。
每个模板对应一个模板ID,比如charge_receipt、prescription_sheet、lab_report。运行时根据门诊ID查出模板内容,渲染引擎用预置数据模型填充。这个方案有点老派,但胜在稳定和可控。模板引擎的语法简单,门诊运营人员经过培训后都能自行调整,开发不用卷入无休止的“改一行字、发一次版”循环。
有一个细节必须提醒:模板里的变量名必须统一。我踩过坑,收费单模板里写了${patientName},而Java传过去的Map里key是patient_name,结果模板渲染出来全是空白。后来加了一层数据字典映射,在渲染前把Java字段名统一转换成模板变量的snake_case格式,这个问题才彻底解决。
6. 上线后遭遇的并发高峰与数据错乱的复盘
6.1 高峰挂号把数据库连接池打满
系统上线后的第一个早高峰,数据库连接池被打爆。HikariCP配置了最大50个连接,平时完全够用,但早上八点半到九点是所有门诊的挂号高峰,线程一多连接很快就耗尽。更可怕的是连接等待导致请求堆到Tomcat线程池,最后整个应用无响应。
排查时发现“真凶”不是SQL慢,而是号源扣减逻辑里有一段远程调用:查询Redis锁→更新数据库号位→创建挂号记录→调用支付接口。这里支付接口是个外部服务,响应时间不稳定。一个慢的外部调用会同时占住数据库连接和请求线程,连接池被拖垮就是必然。
我们的修复方案分两步:第一步把支付调用改成异步,挂号记录先落库,支付成功后通过回调或轮询更新状态;第二步给HikariCP加连接等待超时和泄漏检测,万一有连接没释放,能在日志里看到堆栈。改造之后,早高峰的数据库连接池占用从接近100%降到了70%左右。
6.2 一个租户的错改数据写到其他租户
还有一次事故,至今想起来都冒汗。某租户的运营人员通过后台接口批量修改药品库存,结果发现另一租户的库存也被改了。根因很经典:那个批量处理接口接收了一个外部导入的Excel,里面带了药品ID,但开发人员在处理时没有给每行数据都加上当前租户的ID过滤。
此后我定了一条开发规范:所有涉及数据变动的Service方法,在入口处必须校验当前TenantContext和传入数据的tenantId一致,不一致直接抛异常。同时把MyBatis拦截器从“辅助拼接”升级为“强制校验”模式,update和delete操作如果发现SQL的where条件里没有tenant_id,直接拒绝执行,宁可报错也不能静默放行。
这个强制校验刚开始会引起不少误报,因为确实有极少数配置表是不需要租户隔离的。解决办法是维护一个白名单配置,标注哪些表允许无租户条件操作。白名单的审核权限只放在架构组,普通开发不能随意添加。
6.3 多租户发布与回滚策略
SaaS多租户意味着同一时刻可能有几十个租户在用系统,发布策略必须谨慎。我见过有人直接把新版本推到生产,结果某个门诊特有的配置被新代码默认值覆盖,导致打印格式错乱。后来我们改成“灰度发布+租户白名单”的方式:
先在预发环境跑一遍全量自动化测试,然后把新版本发布到生产环境,但通过网关路由只放行几个内部测试租户。确认无问题后,再按租户灰度百分比逐步放开流量。如果出现紧急问题,不是回滚整个应用,而是网关层把那个租户的流量切回旧版本实例,其他租户继续用新版。
这个思路要求应用是水平可扩展的,新旧两套实例可以同时存在一段时间。我们通过Nacos配置服务做了版本标记,每个租户的流量打到对应版本的实例上。切换耗时基本控制在分钟级,比整体回滚要平滑得多。
如果让我再做一次这个项目,我会把租户上下文校验提前到API网关层,并且在所有对外接口上强制校验租户标识和门诊标识。数据库层面一定要有慢SQL监控和连接池泄漏检测,这两项在SaaS多租户场景下比单机系统重要得多。另外,配置体系的灵活性比想象中更有价值,设计阶段多花一周做配置建模,能把交付阶段省出一个月。