1. 牙科诊所的预约难点:这根本不是一套普通CRUD
先说我接手这个项目时的第一感受——大多数开发接到“预约系统”需求,第一反应都是:不就是患者选医生、选时间、提交一条记录吗?三个表就能搞定的事。但真正钻进牙科诊所的业务里,才发现这套系统要处理的不是“预约”,而是“治疗计划的时间编排”。
我服务的一家口腔连锁机构,几十个医生,每天要排上百个号。前台小姑娘每天早上最怕三件事:患者打电话问还有没有号、医生临时通知上午停诊、某个复诊患者做完治疗当场约下次时间。三件事叠在一起,纸质排班本和Excel完全撑不住。更要命的是,牙科的治疗不是“一锤子买卖”:根管治疗要分三四次,正畸复诊固定每两三周来一次,种植牙从初诊到戴牙冠前后持续四五个月。每一段治疗的预约状态都是有关联的,甚至后一次预约的时间得基于前一次治疗的结果来定。
这就是Spring Boot牙科诊所预约系统的核心使命:把“医生什么时候有空”“患者什么时候需要来”“这次来做什么治疗阶段”三件事在一条时间线上对齐。
除了诊疗本身的复杂性,牙科还有几个行业特有的“脏活”:
- 爽约率特别高。很多患者挂完号转头就忘,尤其是不涉及疼痛的复诊,爽约率常年居高不下,诊所收入直接受影响,所以系统必须在预约前做提醒、在爽约后做标记和限制。
- 医生排班颗粒度要做得很细。牙科一个时段只能服务一个患者,而且不同治疗手段耗时差异极大——洗牙半小时,种植手术台可能要空出两小时,单纯按上午/下午排班根本没法用。
- 首诊和复诊必须分流。老患者复诊需要稳定的时间窗口,新患者首诊又得留着号源,混在一起抢号,前台和医生都会疯。
所以,技术本身不复杂,复杂的是把业务规则翻译成数据模型和状态流转。下面我按我们实际落地的方案,把这套系统的关键设计完整拆开。
2. 号源与排班的数据建模:先想清楚“一个号到底是什么”
2.1 不要一上来就做“医生表 + 预约表”
很多初稿设计把预约直接挂在“医生某天的排班”之下,一张排班表管一个上午或下午,预约记录带时间段。这么做不是不能用,但走到后面一定会撞墙:你没法精确知道某个医生某个小时还剩没有号,也没法解决“根管治疗的复诊即约下周同一时段”这种连续间隔需求。
我把这个模型拆成四个层次:
- 医生层:医生主数据,负责科室(牙体牙髓、正畸、种植、修复、儿童齿科)和职级。
- 排班层:一位医生在某一天是否出诊,以及上午/下午的班次区间。
- 号源层:排班生成的“一个个可预约时辰”。这才是真正的“号”。
- 预约层:患者与国家号源发生绑定关系,加上治疗阶段、状态等业务信息。
理解这个层次之后,数据库表结构就水到渠成了。号源由排班生成,预约引用号源而不是直接引用排班,这是一个关键决策,它让“某时段是否可约”变成了号源表上的一行记录,查询和锁定的逻辑都简单了。
2.2 核心表结构与字段设计
我这边用MySQL,Mapper层选的MyBatis-Plus,建表做了最基础的垂直拆分。核心表的简化结构如下:
医生表 doctor
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar | 医生姓名 |
| department | varchar | 科室 |
| title | varchar | 职称 |
| status | tinyint | 0停诊 1开诊 |
| sort_order | int | 列表排序权重 |
排班表 clinic_schedule
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| doctor_id | bigint | 医生ID |
| work_date | date | 出诊日期 |
| shift_type | tinyint | 1上午 2下午 |
| start_time | time | 班次开始 |
| end_time | time | 班次结束 |
| status | tinyint | 是否停诊 |
号源表 time_slot
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| schedule_id | bigint | 排班ID |
| slot_start | datetime | 号开始时间 |
| slot_end | datetime | 号结束时间 |
| slot_type | tinyint | 1普通 2首诊预留 3复诊预留 |
| status | tinyint | 0空闲 1被占 2锁定 |
| version | int | 乐观锁版本号 |
预约表 appointment
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| appointment_no | varchar | 预约编号(业务唯一) |
| patient_id | bigint | 患者ID |
| slot_id | bigint | 号源ID |
| doctor_id | bigint | 医生ID |
| visit_type | tinyint | 1首诊 2复诊 |
| treat_plan_id | bigint | 治疗计划ID,复诊必填 |
| status | tinyint | 0待确认 1已确认 2已完成 3已取消 4爽约 |
患者表 patient重点字段就三个:mobile(手机号,登录和提醒都靠它)、total_cancel_count(累计取消次数)、miss_count(爽约次数)。
还有一张治疗计划表 treatment_plan,注意它不是完整病历,更准确地说是“方案所需的就诊步骤拆分”。比如根管治疗计划包含“根备”“根充”“牙冠修复”三步,每一步对应一两个预计时长。这样设计的好处是,复诊预约时必须关联到计划的某个步骤,后续统计“这个方案走到哪一步了”只需要按表查,不用翻病历。
2.3 一个容易翻车的设计细节:时间片不能固定切到底
刚开始我把号源时间片切成统一的30分钟,上午12个、下午12个,爽快利落。结果上线第一天,护士长就跑来骂人:洗牙30分钟没问题,根管治疗第一诊要45分钟,种牙手术要预留2小时,统一时间片直接导致两种极端——手术患者还没做完,下一个号的患者已经到前台了;洗牙患者做完之后,下一号空半小时。
后来我们把时间片的生成逻辑改成了“可配置”。排班创建时,默认按30分钟切割,但管理员可以针对某些号源把slot_end调整到具体时间。比如种植医生上午只排3个号,每个号时长90-120分钟。本质上,号源表里的slot_start和slot_end是灵活可调的,而不是必须等分的。
重要教训:在做排班管理界面时,宁可初始版本让前台手动为特殊号源设置起止时间,也要先保留时间段的灵活性,别把模型的扩展性封死。
3. 关键接口设计:从查号到预约成功的完整链路
3.1 号源查询:一个容易被忽略的N+1问题
微信小程序端或者前端H5上,患者进入医生详情页时,通常要看“这个医生未来7天哪些时段可约”。最朴素的做法是:遍历日期,查出该医生的排班,再对每个排班查号源列表,能跑但性能很难看。我见过有人把这个查询直接干出几百毫秒延迟的,数据库天天被扫。
我们的做法是,用SQL直接一次查出7天内所有排班的号源统计:
SELECT s.work_date, s.shift_type, sl.slot_start, sl.slot_end, sl.slot_type, sl.status, s.doctor_id FROM clinic_schedule s INNER JOIN time_slot sl ON sl.schedule_id = s.id WHERE s.doctor_id = #{doctorId} AND s.work_date BETWEEN #{startDate} AND #{endDate} AND s.status = 1 ORDER BY s.work_date, sl.slot_start接口返回时按work_date分组,前端直接渲染出来。这个查询不需要JOIN appointment,因为“当前时间是否有预约”已经被time_slot.status记录了。用一张独立的号源表,最大的好处就是查号源永远是单表扫描,预约状态只是这行的一条属性。
3.2 创建预约:事务与锁的配合
创建预约是整个系统里代码最值得抠细节的地方。伪代码是这个思路:
@Transactional(rollbackFor = Exception.class) public String createAppointment(CreateAppointmentRequest req) { // 1. 校验患者状态:是否黑名单、是否重复预约 Patient patient = patientMapper.selectById(req.getPatientId()); if (patient.getMissCount() >= 3) { throw new BizException("爽约次数过多,需到前台解锁预约"); } if (appointmentMapper.countActiveByPatient(req.getPatientId()) >= 1) { throw new BizException("已有待就诊预约,请先取消或完成后再约"); } // 2. 关键步骤:行锁锁定号源 TimeSlot slot = timeSlotMapper.selectByIdForUpdate(req.getSlotId()); if (slot == null || slot.getStatus() != 0) { throw new BizException("该时段已被约走,请选择其他时间段"); } // 3. 标记占用,生成预约记录 int updated = timeSlotMapper.updateStatus(slot.getId(), 1, slot.getVersion()); if (updated == 0) { throw new BizException("预约冲突,请刷新后重试"); } // 4. 插入预约主记录 Appointment appointment = new Appointment(); appointment.setAppointmentNo(generateAppointmentNo()); // ... 字段组装 appointmentMapper.insert(appointment); // 5. 如果是复诊则绑定治疗计划步骤 if (req.getVisitType() == 2) { updateTreatmentPlanStep(req.getTreatPlanId(), req.getStepId()); } return appointment.getAppointmentNo(); }这里有两个动作一定要在同一个事务里完成,缺一不可:
selectByIdForUpdate:MySQL InnoDB的行锁,同一时刻同一个号源只能被一个事务读到并修改。这是防超卖最基础的一道闸。- 乐观锁版本号的二次校验:即使行锁被绕过(比如分库分表后分布式场景,或者有别的服务直接改库),
version也能兜底。
我一直跟团队强调的思维是:不要把“先查再insert”当成天然安全的逻辑。哪怕现在的诊所规模并发量不高,架构上的正确性也要先立住。现实中更容易出问题的是前端重复提交——患者手抖点两次“确认预约”,两个请求都进来。解决这个问题靠的不是SQL,而是appointment_no的唯一索引,配合前端在收到响应前禁用按钮。
3.3 取消预约与号源释放的策略
取消预约简单,难点在于“什么时候允许患者自行取消”。
- 就诊时间24小时前:患者可自助取消,号源立即释放,但累计取消次数+1。
- 24小时内:只能电话联系前台操作。这个业务规则的背后是牙科医生排班的刚性——医生的时间一旦空出来,很难临时找患者补位,所以越临近就诊时间,爽约成本越高。
- 医生停诊:后台操作批量取消,不记录患者取消次数,同时发送停诊通知。这块需要定时任务和通知模块配合。
号源释放的SQL同样要小心。不能看预约状态是“已取消”就直接把号源状态改成0,万一有前后台并发操作,可能出现“前台刚释放号源,后台又给另一个患者约上了”的竞态。我们的做法是,释放时同时更新time_slot.status = 0,且appointment.status = 3,两个更新放在同一个事务里,用WHERE条件保证执行顺序:
UPDATE time_slot SET status = 0, version = version + 1 WHERE id = #{slotId} AND status = 1affected rows不等于1就说明状态异常,直接抛异常回滚。这也是一种非常朴素的乐观锁写法,但够用且不出错。
4. 状态流转与多端协作:别让预约卡在半路
4.1 三端角色:患者、医生、前台管理
这套系统的角色边界特别鲜明:
- 患者端(小程序/H5):查医生、查号源、预约、取消、查看历史记录和下次就诊提醒。
- 医生端(内部Web):查看自己当天/本周的预约列表、标记“开始就诊”“完成治疗”、临时停诊。
- 前台管理端(Web):核心权限中心,管理排班、处理改约/取消、审核新患者信息、处理黑名单。
权限控制用Spring Security + JWT就够。但要注意一点:牙科诊所内部角色很少用RBAC那套大而全的体系,我们实际就把用户表分成patient和staff两类,staff再带一个role字段(1医生、2前台、3管理员)就能覆盖全部场景。上Spring Security不是为了炫技,核心是为了让“患者不能调医生端接口”“医生只能看自己的预约”这些规则有统一的执行点。
4.2 预约状态机的完整设计
状态设计直接决定后续所有业务逻辑的复杂度。我们最终落地的是下面这条更完整的链路:
待确认 -> 已确认 -> 已完成 \-> 已取消 已确认 -> 爽约自己项目里跑下来,经常会漏“待确认”这个状态。很多预约系统把“提交预约”和“预约成功”视为一步,但实际预约是有时间差的:患者在线上提交预约后,理论上要先经过医生确认才能锁死号源,不然医生临时发现自己当天有其他安排就很被动。规模小一点的诊所为了体验,可以做到“提交即确认”,但状态字段仍然建议保留,给将来做“医生手动确认”留余地。
爽约状态什么时候写入?我建议不靠用户自己点,而是每天凌晨1点由定时任务扫描前一天所有“已确认”但未“已完成”的预约,自动置成“爽约”,并把患者的miss_count + 1。这个逻辑放定时任务里比放在前端或接口里可靠得多,因为前端可能根本没点“完成”,接口也可能因为网络中断而漏掉。
4.3 自带提醒提醒与停诊联动
提醒这块我们踩过一次不小的坑。最初直接用Spring的@Scheduled每分钟扫一次预约表,找出两小时内待就诊的记录,然后调短信接口发送。问题出在两件事上:
- 短信接口偶发失败,没有重试机制,患者没收到提醒,直接爽约。后来加了重试表,每条提醒发送任务落库,状态字段记录成功/失败,失败的由另一个定时任务每5分钟补偿重发一次,重试3次仍失败就进人工处理队列。
- 停诊操作和提醒任务没有联动。医生临时停诊后,系统虽然改了排班状态,但已经生成的提醒任务还在队列里,患者按老时间收到“您明天9点就诊”,到店里才发现医生没来。这个bug把前台小姑娘坑惨了。
现在的做法是,提醒任务生成时必须携带appointment_no和slot_start,如果预约被取消或者work_date对应的排班status变成停诊,定时任务在发送前先校验预约状态,不是1(已确认)就直接跳过。停诊场景还会单独推送一条停诊通知。耦合一解开,很多诡异问题直接从根上消失。
5. 并发与数据一致性:预约场景最容易被忽视的坑
5.1 那个经典的“售罄又约上”的bug
没有并发经验的开发者最容易写出这样一段代码:
// 反例:先查再插,非原子操作 TimeSlot slot = timeSlotMapper.selectById(slotId); if (slot.getStatus() == 0) { appointmentMapper.insert(...); timeSlotMapper.updateStatus(slotId, 1); return "success"; }线程A和线程B几乎同时读到status = 0,两个都走进去,A插入预约成功并把号源置1,B再插入时也会“成功”,因为B根本没检查A的结果。最后就会出现两个患者约上同一个号源。
在诊所场景里,同一个号源被两个人同时约到的概率确实不高,但“不高”和“不会”是两回事。尤其是诊所开通公众号后,晚上8点到10点经常出现一个热门医生的号放出来几十个人同时抢,这个时段并发压力并不低。所以即使现在是单体应用,我也强烈建议在写预约接口时就把并发防线搭好。
5.2 三种防超卖方案与中小诊所的落地选择
| 方案 | 实现方式 | 优点 | 缺点 | 适用规模 |
|---|---|---|---|---|
| 数据库行锁 + 事务 | select ... for update锁住号源行,事务内完成预约写入 | 最简单可靠,无需引入中间件 | 并发高峰期锁等待,吞吐量有限 | 单机部署、每日预约量几百到几千 |
| 乐观锁版本号 | 更新号源时带上version条件,更新失败则重试或提示 | 无锁阻塞,读多写少场景性能好 | 高并发下更新失败率高,体验差,需配合重试 | 预约量中等、冲突不高 |
| Redis预扣减 | 用Redis的decr命令原子扣减号源库存,扣减成功后再写库 | 抗高并发,QPS直接拉满 | 引入Redis,需处理Redis与MySQL的最终一致性 | 连锁多院区、大流量预约活动 |
我在实际落地中选的是第一种:for update行锁 + 事务 + 乐观锁版本号兜底。理由很朴素——牙科诊所不是秒杀系统,它拼的是业务准确而不是吞吐量,行锁方案在每日几千个预约的场景里已经绰绰有余,而且排查问题非常直接。引入Redis反而要多维护一套一致性兜底逻辑,对于小团队这往往是事故的源头。
如果想再稳一点,可以在
time_slot表上建立唯一索引(schedule_id, slot_start),让数据库在物理层面就杜绝同一个排班同一时刻生成两个号源。这个索引成本极低,却能让数据模型本身更健康。
5.3 接口幂等:患者双击引发的脏数据
除了号源冲突,还有一类问题很常见:患者在小程序里点击“确认预约”后,网络延迟导致他没看到结果,又点了一次。两个请求携带相同参数打到后台,如果只按“插入预约记录”来实现,就会出现两条几乎一样的预约记录,号源还只能绑定到其中一条上,另一条变成“幽灵预约”。
我们的做法是:预约请求必须携带一个客户端生成的request_id(UUID),服务端在创建预约前先查appointment_request表里是否已有这个request_id的记录,有就直接返回已有预约结果,没有则插入。同时在appointment.appointment_no上加唯一索引,即使请求重复发送,第二次插入时也会被数据库挡住,返回的只是友好提示“您已预约成功,请勿重复操作”。这是成本最低的幂等方案,适合单体应用。
6. 上线后踩过的坑与可落地的优化方向
6.1 号源碎片化:门急诊排班的隐形杀手
上线一段时间后,我们发现预约页面上经常出现一个奇怪现象:某天上午的号源还有5个,但分散在10点和11点半,中间空了两段。患者请假过来看牙,往前看只有号源,往后一看时间不合适,最后干脆不约了。这就是号源碎片化。
这个问题在上线前很难注意到,因为我们只盯着“号源数量”,没盯着“号源的连续性”。而且牙科的特殊性让碎片化更加明显——复诊患者需要的时间段固定(比如只约周三下午),首诊患者按“当前最早可用”约,两种需求来回穿插,很容易把一天切成零零碎碎的时间块。
目前的优化思路有三个层次:
- 号源分配策略:首诊和复诊的号源分开管理,比如每个排班上午前两个号专门留给可能耗时较长的复诊操作,后几个号留给首诊。首诊一般集中在洗牙、检查这类标准时长操作,更容易填满空档。
- 预约界面引导:患者选时段时展示“连续号源”的提示,用颜色区分紧凑时段和稀疏时段,引导用户选择更连续的时段,降低医生工作压力。
- 改约推荐引擎(还没做):当患者发起取消预约时,同时推荐邻近可用的连续号源给他替换。这个功能改动不大,但对减少空档率非常有效。
6.2 数据统计与经营报表:被忽略的隐形价值
系统上线三个月后,诊所运营最关心的其实不是“预约功能好不好用”,而是报表:哪个医生的号源利用率最高?患者平均爽约率是多少?首诊转复诊率如何?这些指标直接决定医生排班策略和营销投放方向。
我们的做法是,预约表建好之后,每天凌晨跑定时任务把预约数据汇总到stat_daily_appointment统计表里,按医生、科室、日期、来源渠道这些维度展开放好。前台想看报表时查统计表,不用去扫原始预约表。统计表只保留每日聚合结果,数据量小,查询快,做Excel导出也方便。
单靠预约表本身很难直接回答“首诊转复诊率是多少”这种问题,但有了treatment_plan_id关联,就能很清楚地统计出“同一个患者、同一个治疗计划内,首诊预约之后第几天产生了复诊预约”。这类交叉查询在原始表上跑容易慢,聚合表则完全没压力。
6.3 后续演进方向:从单体走向多院区
虽然当前系统的架构是单体,但设计时已经留了一些扩展余地。比如所有表和业务数据都没有硬编码单一诊所ID,将来平移成多租户模型(增加clinic_id字段)就能支持连锁机构。预约业务如果要做多院区统一的号源池,就得引入消息队列和分布式锁,扩展思路是在time_slot表上增加region_id,做跨院区调度时按区域维度分配号源。
我自己在这个项目中沉淀下来的体会是:这类“行业业务系统”真正的难点,从来不在框架选择或炫技技术点上,而在于你是否真的理解了业务运转的底层规则。Spring Boot本身只是一个非常成熟的基础设施,它帮你解决的是“怎么写更省力”的问题,但“写什么才真正有用”,取决于你对医生排班、患者习惯、诊所运营这些事的理解深浅。
如果让我再做一个类似的诊所预约系统,我会把时间花在“医患匹配规则”和“号源智能分配”上——比如根据患者的历史到诊时长动态预测本次操作时长,自动调整号源时段。这个方向对诊所体验的提升,会比再加三个管理页面有价值得多。