先说结论
门诊系统里的患者、处方、库存和结算不是四组独立 CRUD。只要收费成功、库存未扣,或者一个处方被重复结算,页面看起来正常也不代表业务成立。
一致性需要多层共同完成:
- 业务 ID 把患者、就诊单、处方和库存记录关联起来。
- 状态字段限制单据可以从哪一步走到哪一步。
- Redis 锁或幂等键减少同一业务请求被并发重复执行。
- 数据库原子写入和条件更新守住最终数据边界。
- Spring 事务让一组操作要么全部成功,要么全部回滚。
关系字段和 JSONB 怎么分工
患者 ID、就诊单 ID、药品编码、状态、金额等高频关联和强约束字段,适合保留在普通关系列中。它们需要清楚的类型、索引和外键语义。
过敏史、症状补充、既往史等结构可能变化、字段相对稀疏的病历内容,可以放入 PostgreSQL JSONB。这样能减少频繁改表,同时保留结构化查询能力。
JSONB 不是“所有字段都塞进去”的理由。稳定字段放进 JSONB 后,会让约束、关联和统计变得更困难。
GIN 索引适合加速 JSONB 包含、键和值检索,但它有写入和存储成本,也不是所有查询都会自动变快。是否加索引应该由真实查询条件决定,而不是看到 JSONB 就机械添加。
UPSERT 与库存扣减不是同一件事
UPSERT 适合处理“存在则更新,不存在则插入”的原子写入,例如某些可重复提交的业务记录。相比先查再插,它能减少并发窗口和重复记录风险。
库存扣减更关键的是条件更新,例如只有剩余数量足够时才执行扣减。数据库影响行数为 0 时,业务层就应该判断库存不足并终止流程。
因此,不能把“用了 UPSERT”简单等同于“库存绝不会出错”。不同写入场景需要不同的数据库原子语义。
Redis 锁为什么不是最终真相
对同一就诊单或结算单加 Redis 锁,可以减少用户连点、网络重试和多实例并发带来的重复处理。锁粒度应该落在具体业务单据,而不是把同一个患者的所有操作都串行化。
但 Redis 锁不能代替数据库约束:
- 锁可能超时。
- 服务可能在持锁期间宕机。
- 解锁实现不当可能误删别人的锁。
- 其他写入口可能绕开 Redis。
因此数据库条件更新、唯一约束和事务仍然是最终防线。Redis 更像入口处的削峰与防重层。
事务回滚解决什么
假设一张处方包含三种药:第一种扣减成功,第二种发现库存不足。如果每次扣减各自提交,系统会留下“部分发药”的中间状态。
把库存扣减、结算记录和状态更新放进清晰的事务边界后,第二种药失败时应抛出异常,让第一种药的扣减一起回滚。这样用户看到的是明确失败,而不是数据已经悄悄改变。
事务也有边界。跨外部服务、消息队列或第三方支付后,单库事务不再足够,需要幂等、补偿、事件状态或更完整的分布式一致性方案。当前项目并没有把自己包装成这一规模的生产医疗系统。
状态为什么不能只写“完成”
患者、就诊和库存真正串起来,需要状态能表达业务阶段。当前实现使用了简化状态;如果面向更严格的生产流程,更合理的表达应至少区分:
待缴费 -> 已缴费待发药 -> 已发药 -> 已归档
状态拆清楚后,系统才能判断哪些操作允许执行、失败应该回到哪里、重复请求是忽略还是报错。
项目边界
该项目基于 RuoYi 生态进行业务化二次开发,重点展示门诊流程、PostgreSQL 建模和一致性措施,不把通用权限框架包装为个人原创,也不宣称已经达到成熟医疗 SaaS 的生产合规水平。