资讯动态

基于Spring Boot的儿童医院挂号系统:号源并发控制与架构设计实践

发布时间:2026/10/16 8:02:34 来源:尧图企业网站定制
做儿童医院挂号管理系统很多人一开始的想法都很简单把科室表换成儿科、小儿外科再把医生排班表挂上去整个系统不就成了但真正动手之后就会发现难点根本不在CRUD而在于“号源”这件事。孩子生病本来就急家长同时在线抢号数据库里那点余数根本不够看。我最近完整梳理了一遍基于Spring Boot的儿童医院挂号管理系统实现方案从数据库设计到接口联调从并发控制到上线排查这里把整套思路和踩坑记录分享出来。下文适合把医疗信息化当项目来做的人也适合中小型医院自建挂号平台参考。这类系统一旦上线面对的不是几个测试账号而是真实的孩子和家长。号挂错了、号源超卖了、页面半天打不开任何一个小问题都会被焦虑情绪放大。所以整个设计过程我对“稳”的重视程度远高于“花哨”。下面的内容不会只贴一堆代码而是把每个关键设计为什么这么做、实际部署时容易在哪里翻车都讲清楚。1. 项目整体设计思路1.1 为什么不能照搬成人挂号系统很多开发一听到挂号系统先去抄一个通用预约模板改几个字段就完事。但儿童医院有个非常特殊的地方——就诊人不是完全行为能力人系统里必须有监护人和儿童档案两层身份挂号单上要体现出“就诊儿童陪同监护人”否则分诊台和药房都会核对困难。更麻烦的是儿科科室分得很细新生儿科只接收28天内的婴儿儿童保健科接的是健康体检小儿外科和内科又有各自的年龄限制。如果不做年龄与科室的规则匹配家长很容易挂错科。到了诊室又被劝退反而增加现场混乱。所以在设计儿童医院挂号管理系统时第一步不是画界面而是把科室、年龄、急诊通道这些规则先抽象成数据字典。儿童医院最核心的痛点集中在“早晚高峰”和“突发夜间急诊”。平时家长在线挂号量不大但每年流感季、换季降温那几天号源瞬间就被抢空。如果后端只是简单的select余数再update并发一上来就会出现“号源超卖”——一个号被两个家长同时挂走到了前台才发现。而成人医院可以通过排队叫号容忍一定排队儿童医院家长抱着孩子排队体验和风险完全不一样。因此这套系统必须优先解决稳定性和准确性业务上还要为夜诊、发热门诊留出独立通道。这也是我在设计时没有把挂号、支付、取号一股脑塞进一个大service的原因。1.2 技术选型与系统架构技术栈选的是Spring Boot 2.7.x、Java 8、MyBatis-Plus、MySQL 8.0、Redis 6.x。前端采用Vue3加Element Plus通过RESTful API交互。没有引入微服务和消息队列原因很简单儿童医院挂号系统的读写模型虽然对一致性要求高但整体并发量远没到大厂秒杀量级单体应用加Redis已经能扛住绝大多数场景。用Spring Boot可以快速把基础设施搭起来用MyBatis-Plus能省掉大量单表CRUD代码重点精力可以放在事务和并发控制上。选Redis是因为号源查询是典型读多写少场景把当天号源缓存一份能显著降低数据库压力。整体结构是“前端应用 - Nginx - 后端接口层 - 业务层 - 数据层”。后端按controller、service、mapper分三层另有一个独立的job模块跑定时任务用来生成排班、释放超时锁号。数据库用MySQL存储所有业务数据Redis保存登录token、验证码、号源缓存和短时锁。为什么不把所有状态都放Redis因为挂号订单、就诊记录、统计报表都需要持久化一旦Redis重启什么都没了。所以Redis只承担“加速查询”和“临时互斥”最终一致性以数据库为准。这个边界从一开始就要划清楚后面才不会出现缓存和库对不上账的尴尬。1.3 核心功能模块与业务链路按使用角色拆系统分成家长端和管理后台。家长端的功能包括注册登录、绑定儿童档案、按科室或医生搜索、查看号源、挂号、取消挂号、查看挂号记录。管理后台包括管理员管理、科室维护、医生维护、排班管理、号源监控、统计报表。表面看都是常规模块但每个模块都要落到“儿童”这个特殊主体上。比如儿童档案表一个账号可以绑定多个孩子甚至爷爷奶奶手机号也能通过授权绑定同一个孩子。绑定关系如果做简单了现场就会有不少家庭纠纷。核心业务链路一条主线用户选中一个科室系统展示该科室下各医生某天的剩余号用户选择儿童档案并确认挂号后端在事务里锁定一个具体号源生成一条挂号订单订单在15分钟内未支付或未确认则被定时任务释放。这个链路中的关键点是“号源”并不是一个简单的数字而是一个有状态的数据库记录。每个号源从“可约”变为“锁定”再变为“已使用”中间任何一个环节都要保证原子性。理解了这条链路后面看数据库设计和代码实现就不会晕。2. 数据库设计与核心表结构2.1 先想清楚号源是一笔一笔的记录设计表结构前必须先回答一个问题一个医生上午放出30个号这个“30”存在哪里最简单的方式是医生排班表里放一个total_slots和remain_slots字段每次挂号把remain_slots减一。这看起来没毛病但并发情况下会超卖——在高并发下两个请求同时读到remain_slots等于1都执行减一最后变成-1。更麻烦的是数据上根本不知道“1号被谁挂了2号空还是被占用”退号、换号、预约调号都无从做起。所以我把号源设计成“一条记录一个号”排班一次生成30条独立的号源记录每条记录有自己的唯一序号和状态。号源表除了唯一序号还要有状态字段0待放号、1可约、2锁定、3已约、4取消。订单表负责记录挂号的业务语义和号源表一对一关联。订单状态本身另外维护一套例如待支付、已支付、已取消、已完成。两套状态为什么要分开因为号源只管物理号是否被占用订单要管钱、管就诊进度。比如一个家长挂了号但付款失败号源可以立刻释放但订单还要保留失败的记录供审计。如果没有这层拆分后续排查和对账会非常痛苦。2.2 核心表结构示例先说用户侧的两张表。用户表只做登录和身份认证儿童档案单独建表避免一个手机号只能挂一个孩子。绑定关系通过user_id关联但一个儿童也可以被多个账号关照所以实际项目里还会加一张儿童授权关联表。这里示例里先不多写核心是让一对多关系清晰。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_child ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, child_name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL COMMENT 1男 2女, birth_date DATE NOT NULL, id_number VARCHAR(20) DEFAULT NULL, guardian_name VARCHAR(50) NOT NULL, guardian_phone VARCHAR(20) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id) );然后是科室、医生、排班、号源、订单这几张核心业务表。这几张表相对独立但每个表的设计都影响后续代码。例如科室表需要预留年龄段字段医生表通过dept_id关联科室排班表用唯一约束保证同一医生同一天同一时段只能有一条排班。为了后续查询把日期、时段、科室、医生都加上索引。号源表的(schedule_id, unique_no)要建唯一索引这是防止重复放号的关键。订单表的order_no也要唯一索引同时用child_id和register_date做高频检索。CREATE TABLE t_department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(100) NOT NULL, parent_id BIGINT DEFAULT 0, need_age_flag TINYINT DEFAULT 0, min_age_months INT DEFAULT NULL, max_age_months INT DEFAULT NULL, status TINYINT DEFAULT 1 ); CREATE TABLE t_doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_name VARCHAR(50) NOT NULL, dept_id BIGINT NOT NULL, title VARCHAR(50), specialty VARCHAR(200), status TINYINT DEFAULT 1, KEY idx_dept_id (dept_id) ); CREATE TABLE t_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, clinic_date DATE NOT NULL, period TINYINT NOT NULL COMMENT 1上午 2下午 3晚上, total_slots INT NOT NULL, status TINYINT DEFAULT 1, UNIQUE KEY uk_doctor_date_period (doctor_id, clinic_date, period) );号源表和订单表是并发控制的重灾区。关键的并发控制字段是status和version。version字段用来做乐观锁只有在更新时版本号匹配才允许修改。订单表不直接存医生名、科室名而是存id加几个冗余字段冗余字段是为了查询和后台展示更快避免每次都要联表。CREATE TABLE t_register_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, unique_no INT NOT NULL COMMENT 时段内的唯一序号, slot_time VARCHAR(20) COMMENT 例如08:30, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可约 2锁定 3已约 4取消, user_id BIGINT DEFAULT NULL COMMENT 锁号用户, child_id BIGINT DEFAULT NULL, order_no VARCHAR(32) DEFAULT NULL, lock_expire_time DATETIME DEFAULT NULL, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_schedule_no (schedule_id, unique_no), KEY idx_status (status) ); CREATE TABLE t_register_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, child_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, doctor_name VARCHAR(50) NOT NULL, schedule_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, register_date DATE NOT NULL, period TINYINT NOT NULL, amount DECIMAL(10,2) DEFAULT 0, status TINYINT NOT NULL COMMENT 0待支付 1已支付 2已取消 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_child_date (child_id, register_date) );2.3 索引、唯一约束与并发基础为什么要在号源表上建(schedule_id, unique_no)唯一索引因为排班任务一旦重复执行很可能产生两条一模一样的号源记录到时候一个医生一个时段出现了两个“1号”挂号系统就直接乱套了。唯一索引是数据库层面的最后一道防线哪怕业务代码里忘记判重也会插入失败。另外锁号操作要尽量走主键或者这组唯一索引这样行锁的范围最小不会锁住整个表。实际项目中我把状态查询条件也考虑进更新SQL尽量用id ? AND status 1作为更新条件避免业务层先查后改。并发控制我不太建议一上来就建分布式锁因为数据库的一行update本身就带行锁。我的方案是“优先用条件更新的行数判断再用乐观锁version兜底”。比如把可约号源置为锁定时执行一条update ... set status2, versionversion1 where id? and status1如果返回0就说明这个号被别人抢走了。这种方式并发粒度小实现简单也符合儿童医院挂号系统的实际压力。当然如果后续并发量大到单库扛不住再考虑用Redis分布式锁把同一个号源的操作串行化。3. 后端核心功能实现3.1 登录认证与用户上下文登录模块我推荐使用JWT加Redis黑名单的组合。用户登录成功后服务端生成token里面只放userId和一个随机会话id不要把手机号、身份证号塞进payload。密钥通过环境变量注入提交代码时不能出现。Spring Boot里加一个WebMvcConfigurer注册拦截器对需要登录的接口做校验解析token后把用户信息放入ThreadLocal。ThreadLocal需要在请求结束后清理否则线程池复用会串数据这是个隐蔽的坑。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { Claims claims JwtUtil.parse(token.substring(7)); UserContext.set(claims.get(userId, Long.class)); return true; } response.setStatus(401); return false; } }拦截器只负责鉴权业务接口里通过UserContext.get()获取当前用户。注册的时候密码用BCrypt加密不能用MD5MD5撞库太容易了。验证码用Redis保存key里带手机号和当前时间窗口发送频率做限制比如同一手机号60秒只能发一次。这里有个细节儿童医院系统里很多操作是家长代操作不是本人就诊所以登录用户id和儿童档案归属要分开校验。比如挂号接口传入childId后端必须查一下这张儿童档案的user_id是否等于当前用户否则任何人只要知道childId就能给别人家孩子挂退号那是严重事故。3.2 排班管理从模板到号源实例排班模块可以简单设计成管理员手动创建某医生某天的排班然后系统自动生成号源记录。有些高级做法是维护周排班模板比如每周一上午儿内科某医生出诊系统自动为未来一周生成schedule和slot。我用的是模板加定时任务管理员先维护“排班计划”包含医生、weekday、班次、号源数然后每天凌晨三点跑一次任务检查未来第7天是否已生成排班没有就创建schedule并批量插入号源记录。这里建议批量插入用MyBatis-Plus的saveBatch或者手写XML的batch insert避免一条条插入变成性能瓶颈。号源生成算法需要考虑医生上午班时长。假设上午9点到12点共30个号如果按均匀分布生成就诊时间每6分钟一个号那么slot_time可以是9点整、9点06分。但很多医院实际是按“报道顺序”排队不要求精确到分钟所以号源表里的slot_time也可以只写一个时间段让家长知道自己是上午第几个号。我建议生成时把unique_no从1到total_slots排好家长看到“上午第28号”比看到“10:48”更符合医院习惯。同时把schedule和号源存入Rediskey如register:slots:scheduleId保存可约号列表和剩余数量让查询接口直接读Redis。3.3 挂号抢号核心事务挂号接口是整个系统的心脏实现时不能把代码全部写在一个大方法里然后硬加Transactional。我采用的流程是先做参数和权限校验再锁号源锁成功再创建订单。锁号源的SQL如下UPDATE t_register_slot SET status 2, user_id #{userId}, child_id #{childId}, order_no #{orderNo}, lock_expire_time #{expireTime}, version version 1 WHERE id #{slotId} AND status 1如果返回行数为0直接抛“号源已被抢占”。这里必须把order_no在锁号时就写进号源表目的是让后续释放、对账能通过order_no找到对应号源避免用slotId加userId去猜。锁号成功后再插入挂号订单。为了防止用户几分钟内重复提交前端要控制按钮loading后端也要做幂等。我用了一个简单方案使用RedisSETNX register:submit:{userId}:{orderNo}做短暂去重锁过期时间10秒保证同一个订单请求不能并发进来。数据库事务只需要包裹“锁号update 插入订单”这两个操作注意先update再insert避免长时间持有行锁。锁号后设置15分钟有效期定时任务扫描lock_expire_time早于当前时间且status2的记录把状态改回1并清除userId、childId、order_no。3.4 取消挂号与号源释放取消挂号相比抢号要简单但也有坑。家长取消时接口只允许取消自己名下订单并且订单状态只能是“待支付”或“已支付”。取消逻辑是先根据order_no查订单校验归属和状态再把对应号源的status从3改回1最后把订单状态改成已取消。注意顺序一定要先释放号源再更新订单或者在一个事务里同时完成。如果中间任何一步失败都要整体回滚。已支付订单还要记录退款状态简化实现时可以只把待退款标志写入订单由人工或后台批次调用支付平台退款接口。儿童医院有一个成人系统不太有的场景——夜间急诊挂号和退号。很多家长凌晨发现孩子发烧先去急诊挂号天亮又改成白天专家号这时退号要非常迅速号源释放也要即时可见。我做的时候把取消接口单独提出来对“当天号源”和“未来号源”分别处理当天号源一旦取消会立刻在Redis里更新剩余数未来号源则只改数据库家长端刷新后显示略有延迟。另外为了防止恶意取消再抢同一个号系统里加了限制同一个儿童同一天取消超过3次后当天不能再次挂号。这个规则医院可以通过后台配置上线后很管用。4. 前端交互与关键页面4.1 家长挂号端与移动端适配家长端主要使用场景是手机所以页面必须优先做移动端适配。我采用Vue3加Element Plus页面宽度按375px设计列表和表单都使用rem或vw单位来适配。挂号首页显示科室分类按儿童年龄段放“新生儿科、儿内科、小儿外科、儿童保健科”几个入口点击科室后进入医生列表展示医生的职称、擅长领域和未来几天的剩余号。注意医生列表接口要返回当天剩余号而不是总号数不然家长看到“还有30个号”点进去却发现当天已经约满会非常不满。锁定号源后的倒计时提醒是家长端的一个关键体验。前端在用户提交挂号后返回lockExpireTime页面显示“请于14分23秒内完成确认”倒计时结束后如果订单未支付后端会释放号源前端同时收到订单取消的提示。实现时不要只在前端弹窗后台接口也要确保超时真正释放。为了让家长不挂错科科室列表页面我会放一个“症状挂号引导”根据选择的年龄段和症状关键词给出推荐科室。比如“3岁咳嗽发热”推荐“小儿内科”这个功能不需要复杂算法一张规则表就能跑起来但对普通家长非常友好。4.2 管理后台排班和号源监控管理后台核心页面是“排班管理”和“号源监控”。排班管理按周视图展示横向是周一至周日纵向是医生列表管理员点击某个格子设置出诊班次和号源数系统自动生成号源记录。这个页面的难点不在前端而在后端要保证不能重复生成。我在后台生成接口里先查t_schedule的唯一索引如果已存在就提示“该医生当天该时段已排班”不让重复插入。管理员也可以按日期批量复制上周的排班适合医院固定排班的习惯。号源监控页面的数据来自数据库和Redis两种来源。当天号源建议从Redis读因为高频操作都改的是Redis缓存历史号源和统计数据则直接从MySQL查。页面上展示每个医生已约数、剩余数、退号数并用不同颜色标出号源紧张情况。统计报表部分我用的是简单的SQL GROUP BY按天汇总每个科室的挂号量不需要上大数据的库。如果需要趋势图前端用ECharts画折线图接口返回按日期分组的总量数组就行。4.3 接口约定与统一异常处理所有接口统一返回{ code, message, data }格式。code为0表示成功非0表示业务失败HTTP状态码只承担传输层语义。这样做的好处是前端可以统一拦截错误。接口命名我建议符合REST语义例如GET /api/doctor/list?deptId1date2025-06-01查医生列表POST /api/register执行挂号POST /api/register/cancel取消挂号。前端统一封装request工具自动带上token收到401时跳转登录页收到409时用Message提示“号源已被抢占”。全局异常处理器是后端必做的一个类。除了处理参数校验异常、业务异常外还要捕获数据库唯一索引冲突转成“重复操作或号源冲突”的友好提示避免给前端暴露出SQL异常。这里有一个实用技巧在业务异常类里设计一个errorCode字段前端可以根据errorCode做不同交互比如1001表示儿童档案不存在、1006表示当天取消次数超限这样前端不用猜后端返回的message。同时日志里要把请求路径、userId和具体错误原因一起打出来方便线上定位问题。5. 常见问题与排查技巧实录5.1 “明明显示有号却总是挂不上”项目联调阶段最容易遇到的一个问题是家长端显示还有5个号一点挂号就说“号源已被抢占”。第一反应是并发抢号但多数情况不是并发而是Redis缓存里的剩余数和数据库真实号源状态不一致。比如锁号时只更新了数据库忘了删除Redis里对应的可约号列表或者取消号时只更新了Redis数据库事务因为某些原因回滚了两边就出现偏差。我后来的做法简单粗暴所有修改号源状态的操作数据库提交成功后主动删除Redis对应key查询接口发现key不存在就回源数据库并重建缓存。排查这个问题的命令也很简单在Redis里看register:slots:scheduleId对应的剩余数再和执行SQL查t_register_slot状态为1的数量对比。如果数量差很多基本就是缓存一致性问题。还有一种情况是数据库里有脏数据某些号源的状态是2但lock_expire_time已经过期可定时任务没跑。所以每天凌晨任务不仅要生成号源也要扫描清理超时锁两件事要放在同一个job里保证执行。5.2 高并发抢号如何避免“超卖”前面讲了用CAS更新来防超卖实际排查中还会遇到另一种超卖多个应用节点同时操作同一个号源本来应该只有一个成功但因为事务隔离级别或者索引选择问题更新行数判断错误。这种情况排查时要看数据库连接串是否设置了useAffectedRowstrue如果使用默认的MySQL驱动update返回值可能展示的是“匹配行数”而不是“受影响行数”导致返回1但状态没变化。建议统一设置useAffectedRowstrue让更新判断严格按受影响行数返回。如果后续并发进一步加大单行CAS更新虽然安全但会拖慢性能可以考虑在Redis里用分布式锁给号源抢购加一层前置过滤。我的设计是抢号请求先对slotLock:scheduleId做SETNX获取到锁后查询Redis里的号源列表选出状态为1的号然后带着slotId去数据库做CAS更新更新完释放Redis锁。这样即使多个节点同时进来也只有一个节点能真正落库。不过Redis锁要注意加超时时间避免节点宕机锁不释放锁粒度一定要精确到schedule而不是整个系统一把锁否则性能会很差。5.3 定时任务释放锁号失败定时任务是整个系统里看起来不起眼、但最容易出错的部分。最开始我用Spring自带的Scheduled固定频率扫描超时锁号单机运行没问题一旦部署到多个节点就会重复执行。后来改成只在指定节点开启定时任务或者用XXL-Job做分片调度避免同一任务多节点重复跑。扫描SQL要加limit比如每次最多处理500条处理完再继续下一批不要一次性把几万条过期记录加载到内存里。还有一次上线后出现退号后号源不释放的问题查下来发现是事务里先更新了订单状态为已取消再释放号源而释放号源的SQL因为唯一索引冲突失败导致事务回滚但业务提示却已经返回成功。这个问题给的经验是接口返回成功的标识必须在事务真正提交之后不能在事务方法内部还没结束时就提前给前端成功响应。所有数据库操作返回结果后最好再用事务同步后的状态去确认至少日志里要把提交和回滚记录清楚。宁可前端转圈等两秒也不要提示成功实际失败。5.4 上线部署与压测建议部署方案前端打包后由Nginx托管后端打成jar包用systemd托管MySQL和Redis单独部署。数据库连接池我建议用HikariCP核心参数maximum-pool-size不要照着默认10不动按业务并发调整到50以上。每个请求都依赖数据库连接连接池太小的话抢号高峰一上来就大量等待甚至导致接口超时。同时开启MySQL慢查询日志把超过200ms的SQL抓出来优化。后端日志建议按天滚动至少保留30天方便回溯排号投诉。压测前要准备一份模拟数据至少5个科室、10个医生、一周的号源记录。用JMeter模拟200个线程同时抢同一个schedule的号观察成功数和失败数是否等于号源总数。压测中常见的失败是线程安全、缓存击穿、数据库连接池耗尽。修完以后要再测“取消-再挂”的循环场景确保退号释放不会丢号源。最后分享一个小经验上线第一周每天要对比Redis和数据库号源的差异专门写一个核对脚本差异为零才敢认为系统稳定。这个核对脚本虽然简单但能发现很多隐藏问题。整个项目做完技术上的收获倒在其次印象最深的是儿童医院的业务规则远比想象中敏感。比如退号规则不能一刀切凌晨急诊和普通门诊要区别对待再比如科室年龄限制有些家长因为抱着孩子比较着急根本没时间看提示系统必须在选择阶段就给出明确引导。后期我还建议把挂号、取号、看诊状态做成可追踪的环节让家长扫码就能看到前面还有几个人这个功能上线后护士站压力减少很多。如果后续你要在这个系统上做扩展优先做微信服务号或小程序端再接入第三方支付和医保信息接口。技术方案千万条落地上医患都少跑腿才是一条好用的挂号系统。

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

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

免费获取报价 →
↑