资讯动态

SaaS多租户云门诊HIS:Java架构设计与数据隔离实践

发布时间:2026/10/13 11:30:13 来源:尧图企业网站定制
做医疗信息化的朋友应该都被同一个问题卡过区域里一批门诊部、连锁诊所都想上信息系统但每家单独采购、单独部署、单独运维成本高得离谱最后只能一直靠手工台账凑合。我参与过一个Java综合云门诊HIS信息管理系统核心思路就是SaaS多租户加多门诊支持一个平台同时服务几十家门诊每家看到的是自己的业务底层代码和数据库又共用一套项目整体以源码形式交付方便后续做二次开发。这篇文章把里面的架构选型、关键代码和上线后的排坑过程重新梳理一遍适合正在做医疗SaaS、或者准备拿HIS源码做二次开发的团队参考。1. 项目定位云门诊HIS系统到底在解决什么问题1.1 门诊业务链条比想象中长得多很多人一听到HIS第一反应是挂号收费系统真做起来才发现门诊链路其实非常长预约挂号、分诊叫号、候诊队列、医生站接诊、电子病历书写、处方开立、收费结算、药房发药、检验检查开单与报告回传哪一个环节单独拆出来都是一堆业务细节。比如一个简单的挂号动作背后要处理号源可用数、挂号费类型、医保类型、自费与统筹比例、退号锁号规则漏一个点现场就出问题。所以设计这套云门诊HIS时第一件事不是写代码而是把门诊全流程拆成一张业务地图。团队内部需要明确预约挂号和现场挂号是否共用一套号源医生工作站能不能跨科室开检查单药房发药后库存扣减与收费记录怎么保证一致。这些决策直接影响数据库模型、接口粒度以及未来多门诊扩展的复杂度项目里所有技术方案都要围绕这条业务链路来展开。1.2 SaaS化解决的是成本、版本、扩张三个问题传统模式下一个门诊部想上HIS要买服务器、买数据库授权、找实施团队部署调试起步就是几十万小机构根本扛不住。SaaS模式把“买系统”变成“订阅服务”单个租户的初始成本能降到原来的十分之一甚至更低这是连锁诊所和区域门诊部选择SaaS最直接的理由。SaaS的第二个优势是版本统一。几十家门诊跑的是同一套代码产品迭代一次所有租户自动更新不用挨个现场升级公共的漏洞修复、医保接口变化也能一次发布全部生效。第三个优势是快速复制新增一个门诊部只需要开通租户、复制基础配置当天就能开业使用。不过这些收益不是白来的代价全压在“多租户”这一层上如果架构设计不到位后面每一个需求都可能变成数据越权和性能灾难。2. 多租户架构选型数据隔离是命根子2.1 三种隔离方案一场成本与安全性的博弈多租户数据隔离业内基本就三条路独立数据库、共享库独立Schema、共享表加租户ID。独立数据库隔离性最强一个租户一套库出问题影响面最小但数据库实例和连接数会随租户数量线性膨胀运维成本和硬件成本都高只适合客单价高的大客户。共享库独立Schema在MySQL里用的不多因为schema切换依赖数据库账号的search_path多数据源配置不直观还容易引发连接池混乱。共享表加租户ID是SaaS最常见的玩法所有租户共用一套表靠tenant_id字段区分归属实现简单、成本低但风险也最集中代码里漏掉一个租户条件就可能把A门诊的数据暴露给B门诊。这个项目最终选了共享表加租户ID作为默认方案核心原因是目标客群是中小门诊单租户数据量不大完全可以用索引和分表解决问题。但在接口设计和字段规划上从第一天就把租户ID当成最重要的路由字段不能因为现在用共享表就放弃扩展性。将来如果接入大型机构要求独立部署底层只需要换成动态数据源路由业务代码不需要大改。方案隔离性成本运维复杂度适用场景独立数据库最强高高大客户、强合规要求共享库独立Schema较强中中数据库对schema支持良好的场景共享表加租户ID靠代码保证低低中小体量、租户数量多的SaaS2.2 租户上下文与动态数据源路由设计选定共享表方案后最关键的是设计一个“租户上下文”让所有业务代码在任意时刻都能拿到当前租户ID。这里用ThreadLocal最直接一次HTTP请求进来从请求头解析出租户ID放入上下文请求结束清理掉。为了避免每个接口手动写解析逻辑我用了一个全局过滤器统一处理业务层完全不感知租户从哪来只从上下文取。代码结构大概是这样的先用一个TenantContext保存当前租户public class TenantContext { private static final ThreadLocalLong CURRENT new ThreadLocal(); public static void setTenant(Long tenantId) { CURRENT.set(tenantId); } public static Long getTenant() { return CURRENT.get(); } public static void clear() { CURRENT.remove(); } }再通过过滤器解析请求头X-Tenant-Id并写入上下文Component public class TenantFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { String tenantId request.getHeader(X-Tenant-Id); if (StringUtils.hasText(tenantId)) { TenantContext.setTenant(Long.valueOf(tenantId)); } chain.doFilter(request, response); } finally { TenantContext.clear(); } } }这里有个非常容易被忽略的点ThreadLocal在Spring MVC里是安全的因为一次请求通常在一个线程内完成但一旦涉及异步线程、消息队列、定时任务租户ID不会自动传递必须显式传递。后面排坑部分我会单独讲这个问题。考虑到将来可能有租户需要独立数据库项目里预留了动态数据源路由继承AbstractRoutingDataSource根据当前租户ID选择数据源。用这个方法要注意Spring事务管理器默认缓存数据源连接如果某个数据源还没注册切换会直接失败所以数据源必须在启动时就全部初始化好。3. Java后端实现如何从代码层面锁死租户边界3.1 MyBatis-Plus租户插件自动注入tenant_id条件共享表方案最容易翻车的地方是SQL漏写tenant_id条件。比如统计报表、跨表join、历史数据查询只要有一处没带条件数据串门就是一瞬间的事。这个项目用的是MyBatis-Plus的租户拦截器它可以自动在SELECT、UPDATE、DELETE语句中追加tenant_id条件并在INSERT时自动填充tenant_id字段从框架层面减少人为失误。配置核心代码如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); MybatisPlusInterceptor tenantInterceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor inner new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { Long tenantId TenantContext.getTenant(); if (tenantId null) { throw new RuntimeException(租户上下文缺失禁止执行SQL); } return new LongValue(tenantId); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { return TenantIgnoreTable.contains(tableName); } }); interceptor.addInnerInterceptor(inner); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } }这里要特别留意ignoreTable方法。并不是所有表都需要租户隔离比如系统字典表、地区表、租户套餐表这些是全平台共享的主数据如果也强制带租户条件反而查不到数据。项目里维护了一张忽略表清单每次建新表都要确认到底需不需要隔离宁可刚开始严格一点也不要让数据串门。3.2 JWT与请求头双校验防止横向越权租户ID放在请求头里理论上客户端可以随便改比如把X-Tenant-Id改成别人的租户如果后端只认这个头就能直接越权访问其他门诊的数据。这个问题必须在登录认证环节就掐死。项目的做法是登录成功后把当前用户的租户ID写进JWT的claims里后续每次请求过滤器解析JWT得到tokenTenantId同时从请求头拿到requestTenantId两个值必须完全一致否则直接拒绝访问。这样即便有人篡改请求头只要token里的租户ID对不上请求就进不了业务层。Long tokenTenantId parseTenantFromJwt(jwt); Long requestTenantId Long.valueOf(request.getHeader(X-Tenant-Id)); if (!tokenTenantId.equals(requestTenantId)) { throw new AccessDeniedException(租户身份不一致请重新登录); }这套双校验规则写进过滤器后内部测试时专门派人去模拟越权请求从A租户token带B租户的header访问全部被拦截。安全这关越早做越省钱等上线后再补改动成本成倍增加。3.3 缓存与异步任务租户ID必须跟着走加了租户拦截器之后同步业务基本安全了但缓存和异步线程是容易漏掉的两个口子。以Redis为例如果不同租户用同一个key保存数据A门诊写入后B门诊可能直接读到A的数据。项目里所有缓存key统一约定格式{tenantId}:{module}:{bizKey}比如某个科室的号源数就是12345:schedule:8899把这个规则写成一个工具方法禁止业务代码手动拼key。异步线程的问题更隐蔽。医生提交处方后系统要异步推送短信通知患者如果异步线程池拿不到TenantContext里的租户ID最后推送短信的服务拿着空租户去查患者电话要么查不到要么因为租户插件抛异常。给线程池加一个TaskDecorator把主线程的租户ID在任务执行前重新写入子线程上下文执行完再清理问题就干净了executor.setTaskDecorator(runnable - { Long tenantId TenantContext.getTenant(); return () - { try { TenantContext.setTenant(tenantId); runnable.run(); } finally { TenantContext.clear(); } }; });4. 多门诊组织架构与跨机构业务设计4.1 租户、门诊、科室、医生怎么建模多门诊和单机构HIS最大的区别是数据模型里必须多出一个层级。一个租户下往往挂着多个门诊部每个门诊部下设科室科室里有医生医生可能同时在不同门诊坐诊。数据模型不能只按门诊隔离那样患者跨门诊就诊会变得非常痛苦也不能只按租户隔离否则门诊层面的统计和排班就分不清。项目采用了“主档租户级、业务门诊级”的双层归属模型。sys_tenant表对应一个签约客户sys_clinic表对应客户下的门诊部sys_dept和sys_doctor挂在门诊之下。患者主档放在租户级一个患者在一个租户内只有一个主档不管他在哪家门诊就诊而挂号记录、就诊记录、处方记录这些业务数据同时带tenant_id和clinic_id两个字段。这样既保证了全租户的数据硬隔离又支持患者跨门诊看病统计汇总时可以按租户出数也可以按门诊单独出数。4.2 多门诊的排班、号源与预约并发设计门诊排班和号源是这个系统并发压力最大的模块。排班表保存医生在某门诊、某天、某时间段出诊的信息号源表记录该排班下放出的号数。预约请求进来要先判断号源还有没有余量再创建预约记录最后通知医生工作站。每一步都要防止超卖。项目初期直接用select检查剩余号数再insert预约上线第2天就被高并发打脸剩余号变成负数。后来改成数据库乐观锁更新UPDATE clinic_schedule SET available_number available_number - 1 WHERE id #{scheduleId} AND available_number 0;这个SQL靠available_number 0条件保证扣减不会超卖更新影响行数为1才算抢号成功。同时给预约记录建了唯一索引保证同一患者同一医生同一天同一时间段不能重复预约从数据库层把最后一关锁死。号源这种强一致性场景不能光靠应用层判断必须数据库兜底。4.3 跨门诊转诊、收费与数据共享多门诊模式下转诊是绕不开的需求。患者在A门诊挂的号医生看完觉得需要转到B门诊继续检查如果两个门诊的数据模型完全隔离转诊记录、病历资料、检查报告全部对不上那就失去意义了。项目里的转诊单关联了源门诊、目标门诊、患者主档ID目标门诊医生接诊时直接能看到转诊来源和之前的病历摘要实现租户内共享、门诊间协同。收费这块要单独处理因为每个门诊的收费项目定价很可能是不同的同一家连锁机构不同区县的医保政策也可能不一样。所以收费项目表、价格表都是租户级加门诊级双层维护默认继承租户模板价格门诊管理员可以局部调整。检验检查报告则放在租户级共享A门诊开的检验单患者在B门诊取报告系统也能通过患者主档关联到。说白了数据归属的边界要清楚但跨门诊的协同路径也要预先留出来。5. 上线后排查实录那些让我改到深夜的坑5.1 异步线程里租户上下文丢了数据直接串门这个问题上线第一周就出现了。现象是A门诊的医生在处理完患者后系统异步推送一条短信结果短信内容里患者的手机号竟然是B门诊的。追查发现订单推送服务从MQ消费消息后异步线程里没有TenantContext导致MyBatis-Plus租户插件拿不到租户ID直接抛异常代码里又有一段兜底逻辑捕获异常后走了本地查询污染了患者数据。排查过程很痛苦因为本地开发环境很少能复现异步竞态只有在生产环境高并发下才会偶发。定位后用两个手段解决第一线程池全部加上前面说的TaskDecorator让租户上下文随任务传递第二把兜底逻辑里的“异常后继续执行”改成“异常后直接标记失败并人工介入”不能让错误操作偷偷继续。这个原则后来成为团队铁律租户上下文缺失时系统必须快速失败不能静默降级。5.2 手写join查询被插件误伤条件多拼或少拼MyBatis-Plus的租户插件在处理简单CRUD时很省心但遇到手写XML里的复杂join问题就来了。某次统计报表查的都是同一个租户的数据SQL却查出了别的租户的记录打印SQL一看插件给主表加了tenant_id条件但join的子表没加导致两个租户的数据通过join关联上了。这种错在数据量大之后非常致命。排查时我先把MyBatis-Plus打印的完整SQL捞出来手工分析每个表有没有被正确追加租户条件发现插件对带别名的子查询支持并不完美。最终方案是所有涉及跨表、子查询、复杂聚合的统计SQL都放进独立的报表Mapper里用InterceptorIgnore(tenantLine true)显式忽略租户插件然后在SQL里手动拼接租户条件。虽然麻烦但至少每个条件都是看得见的可控性比黑盒自动拼接强很多。5.3 号源超卖与重复预约靠唯一索引保住底线抢号的并发问题前面提过这里再细化一下排查过程。上线第3天线上出现同一天同一时段同一医生被重复预约两例应用日志显示两个请求同时通过了“剩余号数0”的判断都执行了insert。这说明数据库乐观锁更新只能保证扣号不超卖但预约记录创建和扣号更新如果不在同一个事务里还是可能产生重叠。修复方式是在预约表增加唯一索引字段组合是门诊ID、排班ID、患者ID、就诊日期、时间段同一患者在同一排班下只能存在一条有效预约数据库直接拒绝重复插入。这之后再也没有出现过重复预约。现在团队做任何存在并发写风险的业务第一反应都是先讨论唯一索引能不能兜底而不是先写业务判断逻辑。5.4 新租户初始化模板复制不是简单insertSaaS平台每签约一个新门诊都要初始化一堆基础数据科室结构、收费项目、药品目录、字典配置甚至打印模板。项目早期用SQL导出一份模板数据然后手工改tenant_id批量导入结果漏了科室和收费项目的关联关系新门诊开张当天很多项目没法收费运维在现场手动补数据补到凌晨。后来专门开发了一个租户初始化服务把某个内部标杆租户标记为模板租户新租户开通时调用一个接口按业务依赖顺序复制基础数据先复制科室再复制收费项目、药品字典最后复制排班和号源规则。复制时所有主键ID强制重置用MyBatis-Plus的雪花ID重新生成避免从一个库直接导数据导致主键冲突。初始化过程的每一步都输出日志哪一步失败能立刻定位。5.5 高频问题速查表问题根因解决方案数据串门租户条件漏加或线程丢失上下文全局租户插件、任务装饰器、快速失败原则复杂join查出跨租户数据插件对子查询/别名支持不完美手动SQL显式拼条件并忽略插件号源超卖判断和更新存在竞态数据库乐观锁唯一索引兜底新租户基础数据不完整手工导入遗漏关联关系模板租户初始化服务自动复制6. 真实落地体会如果让我重新做一次这个系统我会在第一天就把tenant_id当成和id一样重要的字段所有建表、接口、报表设计都必须经过同一道灵魂拷问这条链路的租户边界锁死了没有。多租户架构本质上不是技术问题而是纪律问题技术框架能堵住大部分漏洞但最后的防线永远是团队对租户边界的敬畏。最后分享一个小技巧登录成功之后把租户的启用状态、套餐有效期放进Redis缓存在过滤器中做一次轻量校验。某个租户欠费或违规被停用后不用等数据库查询接口层直接拒绝所有访问既保护了数据也避免后面的业务错误堆积成更难排查的连带故障。这套云门诊HIS线上稳定运行后支撑几十家门诊部日常接诊、结算和统计基本没有压力最深的感受就是SaaS系统的复杂度不在功能多少而在“看似共享、实则隔离”的每一处细节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑