ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

门诊系统高并发高可用架构设计:从缓存削峰到源码落地

门诊系统高并发高可用架构设计:从缓存削峰到源码落地 1. 项目概述门诊系统为什么必须走“高可用、高并发”这条路先说结论这套系统不是那种“能挂号、能开药”的业务演示 Demo而是奔着“生产可用、扛得住早高峰”这个目标去的。做过医疗信息化的人都懂门诊系统表面上只是个业务系统实际上是个典型的“读多写少、瞬时流量集中、数据强一致”场景。上午 8:00 到 11:00 是挂号、缴费、取号的高峰窗口和自助机的请求像潮水一样涌进来下午则是医生站、护士站的查询和录入为主压力相对平缓。如果系统只是按“普通 CRUD 单机部署”来做高峰期必然卡顿、超时甚至直接宕机——这在医院里是不可接受的场景因为门诊一停患者积压、医生无法工作、窗口乱成一团业务的容错空间几乎为零。这套源码的核心价值是把“高并发、高可用”从口号变成可落地的代码实现。它不是靠某个中间件硬撑而是从架构分层、缓存策略、异步削峰、多级容灾四个维度同时下手配合一套完整的 Java 工程Spring Boot 为主干MyBatis 做持久层Redis 扛缓存RabbitMQ 接异步消息让读者能真正看到“并发来了系统怎么扛、机器挂了流量怎么切”。适合谁看我建议三类人重点研究医疗行业的 Java 工程师想了解门诊业务挂号、分诊、缴费、排队叫号在代码层面怎么做状态流转和异常兜底。做企业级系统的架构师或技术负责人想找一套可参考的高并发业务系统模板特别是“多点挂号并发写”“票号防超发”这类经典场景的解决方案。准备面试“Java 高并发”岗位的开发者这套源码里包含缓存穿透/击穿/雪崩的应对、分布式锁、线程池隔离、熔断降级等高频考点而且是“带着业务场景”的实现比死背八股文好理解得多。后面几节我会按“业务设计 → 架构拆分 → 高并发实现 → 高可用保障 → 源码工程结构 → 问题排查”这个顺序把整个系统的关键设计和代码逻辑逐层讲透。全程会用实际代码片段、核心接口示例和参数计算过程来说话不搞那种“只讲概念不给代码”的空泛方案。2. 门诊核心业务模型与流程拆解2.1 门诊系统的四类核心角色与主链路在写代码之前得先把业务域理清楚。门诊系统再复杂核心角色无非四类患者关注的是“挂上号、看上病、缴上费”。所有压力的源头都在这里。医生关注的是“患者信息准确、病历书写顺畅、检查检验单下达高效”。属于高频率操作者一天能看几十上百个患者。护士/分诊台负责分诊、叫号、状态维护。操作琐碎但每一步都在改状态。收费/药房人员处理缴费确认、发药核销。高峰期压力仅次于挂号。主链路可以抽象成这么一条预约/现场挂号 → 分诊/候诊 → 医生接诊开立医嘱 → 缴费含医保结算 → 检查检验 → 取药/治疗 → 离院。这条链路里挂号、缴费是两个天然的“并发写热点”而医生站、收费处的查询则构成“高并发读热点”。2.2 五大核心模块的职责边界与数据流拆模块的时候我们严格按“领域”切而不是按“页面”切。每个模块划分的依据是“这个模块的数据到底归谁管、被谁改、被谁读”。模块核心职责关键表示例读写特征挂号管理号源生成、挂号和取消、号票防超发register_order, register_source写多读多尖峰集中分诊叫号候诊队列、叫号顺序、过号重排triage_queue, call_number写读均衡实时性要求高医生工作站病历、医嘱、诊断、检验检查申请medical_record, medical_advice读多写少长事务缴费结算费用计算、医保分摊、支付回调、退费payment_order, payment_log写多一致性要求极高药房管理处方发药、库存核销、退药回滚prescription, drug_stock_log写多强一致数据流上模块间尽量不走“同步接口互相调”的老路。比如缴费成功以后要通知药房“可以准备发药了”用的就是异步消息后面细讲而不是让缴费接口同步等药房返回。这个设计决定了整个系统的解耦程度和可扩展性。如果模块间全部搞同步调用高峰期一个慢节点就能拖垮全链路。2.3 号源模型“日期 科室 医生 时段”的四级拆分挂号场景里最容易被忽视、又最容易出并发问题的就是号源模型的设计。很多门诊系统早期用“一个医生一天 50 个号”这种粗粒度模型只存一个总量患者一挂号就 UPDATE 总量减一。这种做法在量小的时候没事量一大就会出三件事行锁竞争激烈、超卖无法完全避免、想限制“上午号”和“下午号”的比例时非常痛苦。这套系统的做法是四段式拆分排班schedule→ 号源池source_pool→ 号段period→ 号ticket。一个医生一天对应一个排班排班下按时间段比如 8:00-8:30、8:30-9:00拆出多个号段每个号段再预生成具体的号。患者挂号时不是“总量减一”而是“从某个号段里取下一个可用号”。这样拆有几个实际好处支持分时段放号上午的号约完了下午的号不受影响符合医院真实管理要求。支持精细锁粒度并发扣号时只锁当前号段不用锁整个排班大幅降低锁竞争。支持号源状态可视化剩余号数、已约号数、锁定位次都能从数据层面直接查询。挂号接口的核心代码逻辑我会在 4.2 节给出那里是整个“防超卖”设计的关键。2.4 门诊状态机的定义与流转约束业务系统最怕“状态随意跳”。一个挂号单可能从“已预约”变成“已取消”也可能变成“已签到”“已就诊”“已缴费”——但绝不允许从“已取消”跳回“已就诊”。所以我们在代码层面用状态机 状态变更记录表双保险把每一次状态迁移都记录下来。核心状态定义如下WAIT_PAY(待支付) - PAID(已支付) - REGISTERED(已挂号) - SIGNED(已签到) - IN_DIAGNOSIS(就诊中) - FINISHED(就诊完成) - SETTLED(已结算) WAIT_PAY - CANCELLED(已取消) PAID - CANCELLED(已取消退号) REGISTERED - CANCELLED(已取消退号)代码里用枚举 合法流转 Map 实现非法流转直接抛业务异常。这一层看似简单实际是医疗系统“数据可信”的基础财务对账、科室统计、医生工作量核算全都依赖状态是干净、准确的。真等到线上数据乱了才回头补状态代价是灾难级的。3. 系统整体架构与关键设计决策3.1 微服务拆分还是模块化单体这是架构选型时最纠结的问题。我直接说结论这套源码做的是“模块化单体”而非“分布式微服务”但保留了向微服务演进的能力。原因很现实门诊系统核心链路短模块间调用密集如果一开始就拆成十几个微服务网络开销和运维复杂度反而会拖垮开发和排障效率。尤其对于中小型医院团队规模有限微服务的基础设施注册中心、配置中心、链路追踪、日志采集一旦不到位系统稳定性会比单体更差。模块化单体的做法是工程还是同一个 Spring Boot 应用但代码按模块分包rpm 模块、triage 模块、doctor 模块、payment 模块等模块之间通过进程内 Service 接口调用不跨网络。等到业务量真正上来、需要独立扩容某一块时再把这些模块升级为独立服务——因为领域边界已经划清楚了拆起来只是“把接口调用改成 Feign/RPC 调用”的体力活。3.2 技术栈选型为什么是 Spring Boot MyBatis Redis RabbitMQ这套技术栈不是追新而是门诊业务场景“逼”出来的务实选择。组件选型核心理由主框架Spring Boot 2.7.x生态成熟、社区资料多、团队上手快、部署简单持久层MyBatis Plus复杂 SQL对账、统计、报表可控性强SQL 性能和事务边界由开发显式管理缓存Redis 6.x抗高并发读、分布式锁、验证码/Token 存储、热点数据如医生排班、科室列表缓存消息队列RabbitMQ挂号成功通知、缴费成功异步出药、对账削峰轻量且可靠连接池HikariCPSpring Boot 默认性能好监控指标完善接口文档SpringDoc OpenAPI自动生成接口文档方便前后端联调选 MyBatis 而不是 JPA是刻意为之。医疗行业有大量对账、统计、报表类 SQL这些 SQL 往往需要精细的 JOIN 和条件组合MyBatis 的 XML 编写方式在复杂 SQL 维护上更直观而 JPA 在复杂查询时容易生成低效 SQL排查起来反而更费劲。当然喜欢 JPA 的团队也可以保留上层封装但底层 SQL 可控性更重要。3.3 分层架构与异常处理规范工程内部按经典五层拆controller → service → manager → mapper ↘ common (异常、工具、常量)每层职责边界很明确Controller 层只做参数接收、简单校验、结果包装不写业务逻辑。所有接口统一返回ResultT包含 code、message、data 三段。Service 层业务编排、事务边界、状态流转控制。事务注解只打在这一层禁止在 Controller 直接操作事务。Manager 层跨模块复用逻辑的收口比如“发号”“库存扣减”“锁获取”这些容易被多个 Service 调用的底层服务。Mapper 层SQL 与数据库交互不做任何业务判断。异常处理统一用RestControllerAdvice兜底。业务异常比如“号源已约满”“该就诊卡已被锁定”抛BizException系统异常数据库连接失败、Redis 超时抛SysException每种异常映射不同的 HTTP 状态码和提示文案。前端拿到的不是一堆堆栈而是“人类能看懂的原因”。这个细节在门诊场景里尤其重要——操作员在窗口面对患者系统弹出一串 Java 异常堆栈没有任何意义必须给一句“当前号源已满请更换号源”这种直白提示。3.4 部署架构的最低可用形态虽然标题里写的是“高可用”但高可用不等于豪华多活而是“任何一个单点挂了业务都不中断”。我们按最小成本实现这一目标两台应用服务器前面挂 Nginx配置 upstream 轮询一台宕机后另一台接管全部流量。两台数据库服务器MySQL 主从复制应用配置读写分离主库宕机后手动或自动切换到从库本系统优先保证实现清晰自动切换交给运维层面选型。两台 Redis 节点一主一从 Sentinel 哨兵Redis 故障时自动 Failover。两台 RabbitMQ 节点镜像队列模式消息不丢至少一个节点存活即可。这套拓扑整体上已经满足“医院门诊系统”对可用性的基本要求应用无状态挂了随便重启数据库有主从至少不丢数据Redis 和 MQ 都有冗余不会因为缓存或消息中间件故障导致全站瘫痪。我们最终在【4、5】两节展开的具体设计都是围绕这套拓扑进行的。4. 高并发场景下的核心实现4.1 热点数据的三级缓存设计本地缓存 → Redis → 数据库门诊系统里“科室列表”“医生排班表”“号源余量”是三个最典型的热点数据。这些数据有几个特点读量极大、实时性要求有差异、数据量不大。比如科室列表几十条数据撑死了但每天早上成百上千个请求都在查它。如果每次都打数据库再好的 MySQL 也会被打出慢查询。我们的处理方式是三级缓存第一级Caffeine 本地缓存。适用于“变化频率极低”的数据比如科室列表、收费项目字典。本地缓存的优势是零网络开销一毫秒内直接返回。失效时间设置 5 分钟即可即使有更新最多 5 分钟延迟完全可接受。第二级Redis 缓存。适用于“变化频率中等、多实例共享”的数据比如医生排班信息、剩余号数。多个应用节点必须看到同一份数据不能各缓存各的否则会出现“明明还有号各节点都以为没了”的一致性问题。第三级数据库。永远的数据最终来源缓存全部失效后由这里兜底。具体到“号源余量”这类数据缓存更新不是“读的时候被动更新”而是“写的时候主动失效”。流程简化如下public RegisterOrder createOrder(RegisterRequest request) { // 1. 先尝试从 Redis 预扣号防止并发超卖 boolean success ticketManager.tryDeductTicket(request.getPeriodId(), request.getCount()); if (!success) { throw new BizException(号源不足); } // 2. 写业务库订单表 号源状态流转 RegisterOrder order buildOrder(request); orderMapper.insert(order); // 3. 删除缓存中的余量数据让下一次读取回源数据库 redisTemplate.delete(CacheKey.ticketRemain(request.getPeriodId())); return order; }这个“先扣缓存、再写库、再失效缓存”的写法比单纯“写库后更新缓存”更稳——因为更新缓存本身也可能覆盖掉并发请求写回的新值失效缓存则保证下次读取强制走一次数据库拿到最新值后再重新缓存。注意不要把缓存当作数据库的影子。缓存只扛读真正的可信数据永远在 MySQL 里宁可缓存全没了数据库一扛系统最多慢几秒不会错。4.2 号源扣减与分布式锁防超卖的三层保障“号源防超卖”这个需求我把它当作整个系统的“题眼”。现场挂号 线上预约同时进行同一秒可能有几十个请求要拿同一个医生的号。如果不同步控制数据库层面就可能出现“最后 1 个号被 5 个人同时抢到”的事故。三层保障的做法如下。第一层Redis Lua 脚本原子扣减。这是最关键的一层。号源余量先预置在 Redis 里比如 key 为 period:ticket:1001value 为 50。扣号时执行一个 Lua 脚本if (tonumber(redis.call(get, KEYS[1])) tonumber(ARGV[1])) then redis.call(decrby, KEYS[1], ARGV[1]) return 1 else return 0 endRedis 的 Lua 脚本是原子执行的多条命令不会被并发插入这就从“源头”卡死了超扣的可能。只要 Redis 还活着号源扣减就一定是准确的。第二层分布式锁兜底。针对的是同一个患者“重复点击挂号”的问题。比如一个患者手抖点了三次“提交挂号”如果没有锁可能生成三笔订单。这里我们按“患者 ID 排班 ID 日期”加分布式锁只有第一次请求能拿到锁并继续执行业务后面两次直接提示“请勿重复操作”。锁用 Redis SETNX 实现设置过期时间 3 秒防止业务执行过程中锁永久占用。实际上重复点击这种场景不能只靠前端按钮置灰后端必须兜底——因为前端限制挡不住脚本请求也挡不住网络重试。第三层数据库乐观锁。即使前两层全部失效比如 Redis 宕机降级到数据库扣减数据库层用乐观锁兜底update register_source set remain remain - 1 where period_id #{periodId} and remain 0这个 SQL 的where remain 0条件天生就是一份“数据库级别的防超卖”。当 update 影响行数为 0 时代码里直接抛“号源不足”。三条保险同时在线才能在“缓存过期 Redis 降级 网络抖动”这种极端情况下依然保证不超卖。三层保障的取舍逻辑也要说清楚Lua 脚本扣减是性能最优路径分布式锁解决的是“业务重复”而非“号源数量”乐观锁是最终的兜底防线。不要把三层混为一谈它们保护的对象不一样。4.3 削峰填谷RabbitMQ 异步解耦与消息可靠性挂号这个动作本身有价值但它引发的“衍生动作”非常多要给患者发通知短信、要通知分诊台有新患者、要更新排队队列、要做统计埋点。如果所有这些动作都在一次 HTTP 请求里同步完成接口响应时间会从 200ms 被拖到 1500ms而且任何一个衍生环节出问题都会导致主流程失败。处理方式主流程只做“建单 扣号 返回成功”其余全部异步化。挂号成功后发送一条REGISTER_SUCCESS消息到 RabbitMQ 的order.exchange然后下面三个消费者分别处理各自的关注点sms.consumer发送患者通知短信queue.consumer更新分诊叫号队列stat.consumer更新运营统计报表消息可靠性方面有三处细节容易被忽略Publisher 确认发送方开启 confirm 模式消息落到 Exchange 后回调确认失败则重发或记入异常表。消费者手动 ACK消费者处理完业务逻辑后手动确认处理失败且重试多次后进入死信队列而不是无限重试导致消息积压。消息幂等消费者必须按业务唯一键做去重。比如orderId10086的通知消息重复投递时消费者里要用“订单通知记录表”判断是否已发送过否则网络抖动触发重投时患者会收到重复短信。这套“异步 削峰”的收益在缴费场景体现得更明显。缴费成功后要调用医保接口、第三方支付接口对账这些外部接口慢的能到 3-5 秒。如果同步等待门诊收费窗口的操作员就只能在电脑前干等。异步化之后窗口秒开缴费单外部对接在后台悄悄完成用户毫无感知。4.4 线程池隔离与熔断降级防止“单点拖垮全局”门诊系统调用的外部依赖不少医保接口、支付渠道、短信运营商。任何一个外部服务变慢如果不做隔离都可能倒灌到主线程池把整个应用的 Tomcat 线程吃光最后医院所有窗口全部卡死。这里用“线程池隔离 熔断降级”两手解决。每个外部依赖分配独立线程池线程池参数通过压测标定核心线程数 8最大线程数 20队列容量 2000拒绝策略CallerRunsPolicy线程池满了后任务退回调用线程执行至少保证部分请求能继续走完而不是直接丢请求当某个依赖连续失败率达到阈值比如 10 秒内失败比例超过 50%触发熔断后续请求快速失败并走降级逻辑。降级逻辑必须“在业务上可接受”医保接口超时 → 先按自费结算下单提示“医保结算稍后处理”短信通道不可用 → 不阻塞主流程记录后补发微信支付回调延迟 → 以本地支付状态为准定期与支付渠道对账注意一点熔断和降级不只是技术方案更是业务方案。你必须在产品层面提前确认“哪些功能即使在依赖异常时也必须可用”并在代码里为这些功能准备兜底路径。门诊流程里“挂号和开药”是必须可用的主路径“通知和统计”是允许延后的辅助路径。两者优先级完全不同。4.5 数据库读写分离与分表策略读多写少的场景读写分离几乎是必然选择。主库承接写操作挂号、缴费、状态变更从库承接读操作查询病历、查询号源、查询报告。MyBatis 层通过动态数据源注解在 Service 方法上声明DataSource(slave)即可走从库。读写分离会不会读到脏数据会。比如患者刚缴费成功立刻刷新查询缴费状态如果请求被路由到从库而主从复制还没完成就可能看到“未缴费”。应对方式是关键写操作后的读强制走主库比如“支付回调后查询订单详情”普通查询科室列表、历史数据浏览走从库。这套系统在DataSource注解的设计上允许方法级指定不搞全局一刀切就是为这类“读自己的写”场景留的口子。分表策略上核心依据是“数据量和查询维度”。挂号订单表按patient_id的哈希值分 16 张表支付流水表按pay_order_id分 16 张表。这样单表数据量控制在合理范围内且按患者维度查询永远只需要路由到一张表。但是注意分表会带来“跨表查询、统计报表难写”的新问题。我们的取舍是高频单查走分表低频统计走异步 ES/数仓。现阶段没有引入 ES而是将统计类需求全部通过“每日定时任务 汇总表”的方式解决报表延迟一天无伤大雅但系统复杂度少了一个大件。5. 高可用保障体系从代码到运维的完整闭环5.1 优雅停机与滚动发布如何不让患者感到系统在升级“高可用”不只是抗高并发还包括“升级过程中业务不中断、重启过程中请求不失败”。这套源码在应用层实现了两个关键支撑Spring Boot 优雅停机配置加入以下配置后应用收到关闭信号时先停止接收新请求等待已接收请求处理完毕超时 30 秒最后才销毁容器。避免“升级重启的瞬间正在提交的挂号请求被硬生生杀掉”。server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30sNginx 主动摘流量虽然优雅停机能挡住一部分风险但保险做法是发布前先把某台机器从 Nginx upstream 里摘掉down标记发布完成再恢复。整个过程在凌晨两三点进行患者几乎无感知。5.2 全链路监控与预警阈值设定高可用体系的另一半是“能提前发现问题”。系统内置了一套监控告警逻辑JVM 层通过 Actuator Micrometer 暴露指标堆内存使用率、GC 暂停时间、活跃线程数。应用层核心接口的 P99 响应时间、错误率、成功量。重点关注“挂号”“缴费”“取号”三个接口。中间件层Redis 命中率、MQ 消息积压量、连接池活跃连接数。数据库层主从复制延迟、慢查询数量、锁等待时长。阈值设定是有讲究的不是拍脑袋。比如“挂号接口”的 P99 超过 2 秒就告警患者可感知的临界值约在 2-3 秒“MQ 积压量”超过 5000 条告警积压超过 10 分钟说明消费者异常“主从复制延迟”超过 5 秒告警读请求可能读到旧数据。每个阈值的背后都有业务含义不是拿一个通用模板硬套。告警渠道接入钉钉/企微机器人群里直接 push值班同学不用盯屏幕手机就能收到消息。5.3 定时对账与数据一致性兜底分布式环境讲究“最终一致”但“最终一致”不能靠运气必须有对账机制。系统的核心对账有三条挂号订单对账每 30 分钟统计一次各支付渠道的账单与本地支付流水核对金额和笔数差异数据生成异常工单。号源余量对账每 5 分钟通过数据库真实余量与 Redis 缓存余量做比对不一致时以数据库为准并修正缓存。这个对账是“缓存一致性”的兜底手段也顺带解决“Redis 宕机期间扣减丢失”导致的数据偏差。药品库存对账药房发药后库存扣减与处方单状态校验防止“处方已发药但库存没扣”或反向问题。对账的意义不止是“发现问题”更是“证明系统可信”。医院系统的管理者最怕“数据说不清”对账逻辑是撑起这份信任的骨架。实操心得对账任务一定要独立于主流程禁止与业务代码耦合。对账逻辑跑挂了不能影响主业务否则就违背了“兜底保护主流程”的初衷。我的做法是对账任务单独开一个 Job 应用跑数据库单独分配一个只读账号连不上就静默重试不产生业务告警。5.4 容量评估与压测数据参考上线之前必须知道系统能扛多少量。这里提供一组基于实际环境2 核 4G 两台应用 4 核 8G 单库的 JMeter 压测参考数据场景并发线程数QPSP99 响应时间结论查询科室列表缓存命中200约 6200/s15ms远超峰值挂号接口写扣号200约 320/s480ms可承受预留 50% 余量缴费结算含支付回调模拟200约 180/s730ms受外部依赖影响分诊叫号状态流转100约 500/s220ms完全满足以一家日门诊量 3000 人次的医院测算早高峰 8:00-9:00 的瞬时挂号请求约 600 笔/分钟折算 QPS 只有 10。即使全部走挂号接口这套系统的 TPS 余量也非常大。真正要关注的是缴费涉及外部系统和报表统计涉及大量扫描前者靠异步削峰后者靠离线跑批解决。压力测试还有一个重要目标找到系统的“崩溃点”。比如 Redis 连接池耗尽时系统表现如何、数据库连接打满时是否快速失败。我的建议是在压测时故意调低连接池上限比如改到 5看系统会不会快速返回“系统繁忙”而不是无限阻塞。一个“新请求快速失败”的系统远比一个“所有请求都在超时等待”的系统健康。6. 源码工程结构与核心代码解读6.1 工程目录模块划分outpatient-server/ ├── outpatient-common/ # 通用工具、异常、常量、上下文 ├── outpatient-system/ # 系统管理用户、角色、权限 ├── outpatient-register/ # 挂号模块排班、号源、挂号单 ├── outpatient-triage/ # 分诊模块队列、叫号、过号管理 ├── outpatient-doctor/ # 医生站模块病历、医嘱、诊断 ├── outpatient-payment/ # 缴费模块费用计算、账单、支付回调 ├── outpatient-pharmacy/ # 药房模块处方、发药、库存 ├── outpatient-job/ # 定时任务对账、号源预生成、报表统计 ├── outpatient-admin/ # 管理后台 API └── outpatient-web/ # 前端资源或对接说明模块之间依赖关系是单向的register不依赖paymentpayment不依赖pharmacy通过消息队列做事件通知而不是直接类调用。这样未来拆微服务时只需要把每个模块的 Spring 配置独立打包即可几乎不用改业务代码。目录结构本身就是“模块化单体未来可拆分”的最好证明。6.2 核心接口与关键代码解析挂号接口开头已经给过一部分代码。这里再放一个“缴费结算”的核心代码片段重点展示“本地事务 异步通知外部”的写法Transactional(rollbackFor Exception.class) public PayResult settlePayment(PaymentRequest request) { // 1. 本地账单状态检查 PaymentOrder order paymentOrderMapper.selectByIdForUpdate(request.getOrderId()); if (order.getStatus() ! PaymentStatus.WAIT_PAY) { throw new BizException(订单状态不允许缴费); } // 2. 计算应收金额含医保分摊 BigDecimal receivable calculateReceivable(order); // 3. 更新本地账单状态为“支付确认中” order.setStatus(PaymentStatus.CONFIRMING); order.setReceivable(receivable); paymentOrderMapper.updateById(order); // 4. 发送异步消息通知支付渠道确认、药房准备发药 rabbitTemplate.convertAndSend( PaymentExchange.EXCHANGE, PaymentExchange.ROUTING_CONFIRM, new PaymentConfirmEvent(order.getId(), receivable) ); return PayResult.of(order); }注意Transactional只包住了本地数据库操作。RabbitMQ 的发送放在事务内是有讲究的如果消息发送失败事务回滚避免“账单已更新但外部不知道”的不一致。虽然极端情况下 MQ 发送成功但数据库事务提交失败消息重复但兜底有幂等消费机制整体最终一致性是安全的。6.3 关键表结构设计订单表与流水表分离门诊系统的表设计里有一条铁律订单表和流水表必须分离。订单表保存“当前状态”比如待支付、已支付、已退费流水表保存“每一次状态变化的痕迹”什么时候从待支付变成已支付操作人是谁渠道是什么。这套设计在财务审计、争议处理时是唯一可靠依据。以支付订单为例CREATE TABLE payment_order( id BIGINT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, patient_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, receivable_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT 0待支付 1支付确认中 2已支付 3已退费 4已关闭, channel VARCHAR(20), create_time DATETIME NOT NULL, pay_time DATETIME, update_time DATETIME NOT NULL, UNIQUE KEY uk_order_no(order_no), KEY idx_patient_id(patient_id) ); CREATE TABLE payment_log( id BIGINT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, action VARCHAR(20) NOT NULL COMMENT CREATE/PAY_CONFIRM/PRE_REFUND/REFUND/CLOSE, from_status TINYINT, to_status TINYINT, operator VARCHAR(50), remark VARCHAR(255), create_time DATETIME NOT NULL, KEY idx_order_no(order_no) );为什么要单独建流水表因为“金额”和“状态”都是敏感字段如果直接在订单表上改来改去时间一长就说不清之前的操作路径。有了流水表任何一个订单的状态历史都能完整回溯这对医院财务对账是刚需。6.4 号源预生成任务的设计思路每天零点前系统会执行一个“号源预生成任务”根据未来 7 天的排班计划为每个医生每个号段生成具体的号。这个任务放在outpatient-job模块里核心逻辑有三步排班校验医生是否出诊、是否停诊、科室是否开放。停诊时自动释放已占用号源并通知患者改号。号源生成按号段配置如上午号 50 个、下午号 40 个批量插入号源表状态都为“未释放”。余量写入 Redis将每个号段的初始余量预置到 Redis为第二天的高并发扣减提前“上膛”。这个任务的难点在于“排班变动后的补号与释放”。比如某个医生原计划出诊但临时停诊那么已预约的患者全部要收到通知已占用号源要回退到可分配池。系统通过“状态机 消息通知”实现自动处理不靠人工干预。这对我来说是整条业务链里最容易出错、也最需要测试覆盖的环节——排班一变患者体验立刻受到冲击。7. 实践中的典型问题与排查方法7.1 问题实录早上 8 点挂号高峰期Redis 连接池被打满现象早高峰开始后 5 分钟运维告警“Redis 连接获取超时”挂号接口大量报错。排查过程先看 Redis 服务端连接数发现连接数持续攀升到上限再看应用端日志所有获取连接都在等待再看lettuce连接池配置发现默认maxTotal8完全不够用。同时发现代码里有大量“读余量”操作未使用本地缓存每个请求都去 Redis GET 一次导致连接被短时占满。修复方案一是将连接池上限调到 100并设置合理等待时间500ms 超时则降级查库二是号源余量的读操作加上 Caffeine 本地缓存减少 Redis GET 次数短时间内的“余量显示”允许微小误差比如 3 秒内不刷新由对账任务最终保证正确。高峰期后本地缓存失效数据恢复精确。这个问题的根源是“读放大”。明明一个 GET 请求只需要 5ms但并发 500 个请求同时来Redis 单线程处理不过来连接等待就成瓶颈。高并发场景下缓存不只是“查得快”还要“查得少”。本地缓存 Redis 两级配合才是性价比最高的方案。7.2 问题实录支付回调重复通知导致重复入账现象某天财务对账发现一笔 300 元的缴费被记录了两条支付流水。排查过程查支付回调日志发现支付渠道因为网络超时重试了 3 次而我们的回调处理逻辑里没有做“渠道交易号唯一性校验”每次都当作新流水插入。修复方案回调处理入口处增加幂等校验以“渠道交易号”为唯一键查流水表存在即返回成功响应不重复处理。同时在payment_log上建唯一索引uk_channel_trade_no(channel, trade_no)数据库层面也挡住双保险。这个案例给团队的教训是所有外部系统的回调、通知都必须默认“会重复”并在代码层和数据库层同时做幂等。7.3 问题实录主从延迟导致患者刚缴完费看不到结果现象患者缴费成功后窗口刷新页面缴费状态依然显示“未缴费”患者开始焦虑。排查过程加分诊台操作员反馈后查数据库主从延迟发现高峰期主库写入压力大从库延迟一度超过 4 秒。缴费完成后前端立刻查询请求被路由到延迟中的从库就读到了旧状态。修复方案对“刚写完立即查”的场景做了读路由强化——通过自定义DataSourceRouter识别上下文里“最近是否有写操作”的标记有则强制走主库。查询间隔超过 2 秒后恢复走从库。这比“全部查询走主库”的性能损耗小得多又解决了数据实时性问题。7.4 问题实录RabbitMQ 消费者线程阻塞导致消息积压现象早晨大屏显示“待处理消息 20000”短信通知大面积延迟。排查过程看消费者日志大量消息处理抛出连接超时异常。定位到短信通道供应商的 API 在早高峰发生了限流单个短信发送耗时从 200ms 拉长到 5 秒消费者线程全被外部 API 拖住后面的消息全部排队。修复方案不用 RabbitMQ 直连外部 API改为消费者先把消息落地“通知任务表”另开定时任务批量限速发送短信。这样即使短信通道抖动也只是任务表积压不会阻塞整个消费链路。同时为短信通道单独配置线程池和熔断触发熔断后自动切换备用通道。7.5 常见问题排查速查表症状排查方向定位手段接口响应变慢数据库慢查询 / Redis 连接池 / 外部依赖查看慢 SQL 日志、Redis 监控、链路追踪耗时分布号源余量不准缓存与数据库不一致 / Lua 脚本误用跑对账任务核对检查扣减逻辑是否走 Lua消息重复消费缺少幂等 / 手动 ACK 失败重投查消息 ACK 参数核对消费端幂等唯一键定时任务重复执行多实例部署未做分布式锁检查 Job 是否配置了 SchedulerLock支付回调丢失渠道侧未收到 2xx / 消费者异常查死信队列对比渠道侧账单与本地流水内存持续增长本地缓存未设置过期 / 大对象堆积观察 Caffeine 命中率与 GC 日志8. 一些基于实际经验的建议最后分享几点我在做这类系统时沉淀下的个人判断不一定写进任何需求文档但非常管用。架构永远服务于业务规模和团队水平。如果没有“日门诊量 5000 人次”的现实业务数据支撑一上来就搞全套微服务 Kubernetes 网格注定是给团队挖坑。我给这套系统做的是“模块化单体 事件驱动 必要中间件”在中小规模医院场景里这套组合已经能覆盖 99% 的压力场景而且一个 Java 工程师半个月就能上手维护。等真需要拆微服务时领域边界早已划好迁移成本是可控的。“高并发”的核心不只是性能而是“流量不冲垮业务”。门诊的高峰流量并不是真正的“海啸”而是“短时脉冲”。设计思路不是无限堆机器而是“缓存扛读、队列削峰、锁防超卖、降级保命”。把这四件事做好比堆再多的服务器都有用。别一上来就讨论多少万 QPS先把业务峰值算清楚——我测算过的结果是一个 3000 日门诊量的医院挂号峰值 QPS 约 10-20这还是全员集中在同一分钟操作的时候。这时候系统真正的瓶颈根本不是并发而是“可用性”——不宕机、不超卖、不出错。代码层面事务边界要短不要在事务里做远程调用。这算是一条铁律。很多人写支付功能习惯在Transactional里直接调微信支付接口结果外部接口超时 30 秒数据库事务独占 30 秒连接池被耗尽。正确的做法是事务内只做本地数据库变更远程调用放在事务外或者用异步消息触发。这个教训接手的每一个维护者都值得反复强调。另外每一个异常消息都要写“人话”。医院窗口的操作员不是程序员他们对“系统繁忙请稍后重试”的理解远好于“redis.clients.jedis.exceptions.JedisConnectionException”。统一的异常提示文案看起来只是个细节实际上决定了信息科每天接到多少求助电话。如果你正准备开发或重构一套门诊系统我的建议是先把“号源防超卖、缴费幂等、状态机流转、异步削峰、多级缓存”五个核心场景想清楚再动手写代码。这五个场景拿下了系统的骨架就稳了剩下的报表、后台管理、权限控制都是围绕骨架的填充工作。还有尽量保留一份真实脱敏数据来做压测开发环境那种“1 个医生、10 个号源”的数据规模永远暴露不了并发问题。
返回列表