牙科诊所的预约管理,看着是个不起眼的业务模块,真正落地做一遍才知道里面全是细节。这套java_ssm63牙科诊所项目预约管理系统,本质上是围绕“医生的时间”和“患者的预约”这两条主线,用SSM架构搭起来的业务系统——Spring管对象、SpringMVC管路由、MyBatis管数据库,三者配合把诊所里最容易出错、最招人骂的预约环节变成可查询、可追溯、可统计的线上流程。它解决的核心问题,是传统电话加Excel排班模式下漏记、重约、撞号、医生停诊通知不过来的乱象,让前台从翻表格的焦头烂额里解脱出来。
这篇文章适合谁看?想找个完整SSM项目做课程设计或毕业设计的在校生,刚入行想参考一套标准分层结构的初级工程师,还有想给自家小诊所做信息化的负责人。文章会讲清楚需求怎么拆、数据库表怎么建、核心预约流程怎么实现、并发重复预约怎么堵,最后把开发中踩过的坑集中盘点一遍。你看完不需要再从零摸索,可以直接把里面的方案拿来改一改用。
1. 项目立项与需求梳理
1.1 牙科诊所预约业务的核心矛盾
我接触这家诊所的时候,前台最痛苦的场景是这样的:下午两点到四点是复诊高峰,电话平均一分钟响一次,每个电话都要确认医生姓名、日期、时间段、患者信息,然后翻Excel看这个时段还有没有位置。最崩溃的是,手工记录的预约和Excel对不上——上午电话里答应患者的时间,中午忘了填进表里,下午患者来了前台查不到记录,当场就吵起来。
预约系统的核心矛盾,就是时间片资源的管理。医生的出诊时间被切成一个个离散的时段,每个时段只能服务一个患者,一旦被占用就要立刻在全局可见的状态下标记为不可约。这个矛盾看起来简单,但在数据库层面涉及的是并发的读写一致性:两个不同的入口(电话、前台)同时给同一个时段安排患者,系统必须保证只有一个成功,否则就会产生两个患者抢同一个坑的情况。
把这个矛盾往深了挖,还能拆出几个子问题。第一,预约的变更经常发生,患者要求改约、医生临时停诊,每次变更都要产生记录,方便后续追溯到底是谁在什么时间动过这条数据。第二,爽约行为必须被识别和统计,否则医生一天白等好几个患者,收入和时间都被白白浪费。第三,前台需要快速看到“某医生今天剩哪些可约时段”,这要求系统实时统计而不是离线汇总。这些子问题,直接决定了数据库表怎么设计、状态字段怎么定义、事务边界怎么划分。
1.2 功能边界与角色权限梳理
需求梳理阶段最容易犯的错误是想把所有功能都做进去。我和诊所的负责人对了几轮之后,把系统圈定为三个端、三条主链路。
先说角色,整个系统只有三类用户:管理员(一般是诊所老板或店长)、医生、前台。患者在这个系统里不登录,预约行为由前台代操作,这大大简化了权限模型,也符合大部分中小诊所的实际用工方式。管理员管医生信息、排班设置、全局数据统计;医生只能看自己的排班和个人工作量;前台负责录入患者、查询可约时段、完成预约、改约、取消、核销。
三条主链路分别是:排班链路(管理员创建排班、生成可约号源、处理停诊)、预约链路(前台查询号源、创建预约、就诊核销、取消和改约)、统计链路(预约量统计、医生工作量统计、爽约率统计)。三个端、三条链路定下来之后,功能清单就自然清晰了,后续开发的每一行代码都能在这个框架里找到对应位置。
这里我特别想强调一点:需求边界一定要砍得住。一开始诊所提了很多想法,比如在线支付、患者App、自动叫号,全被我劝回去了。预约管理系统的核心是把“预约”这件事做扎实,其他功能可以二期再接,否则项目会因为范围蔓延拖成烂尾。砍需求的逻辑很简单:这个功能不做,诊所当前的痛点能不能被解决?如果不能,就是核心功能必须做;如果能,就放在二期甚至是三期。
2. 技术选型与工程结构设计
2.1 为什么选SSM而不是Spring Boot
现在在Java圈子里,Spring Boot几乎是默认起步框架,为什么这个项目还选SSM?如果是面向企业的快速交付,我也会推荐Spring Boot。但SSM在这个场景里有它不可替代的价值,尤其是对学习型项目和存量系统改造来说。
第一,SSM是理解Java Web底层的绝佳载体。用SSM你会被迫自己搞定web.xml的配置、Spring容器的初始化、SpringMVC的DispatcherServlet映射、MyBatis的SqlSessionFactory装配。这些东西在Spring Boot里都是自动配置的,你用Spring Boot写一年项目,可能都不知道DispatcherServlet到底注册在哪个位置。而Java基础面试题里常问的Spring容器、Bean生命周期、AOP这些概念,在SSM项目里你能真正摸到它们的落脚点,而不是背了一堆理论却不知道在代码里长什么样。
第二,SSM对环境的包容性更强。很多课程设计和毕设场景里,JDK版本还是1.8,Tomcat跑在单独的服务器上,数据库可能是MySQL 5.7。Spring Boot 2.x对老环境兼容性还行,但Spring Boot 3.x直接把JDK最低要求抬到17,很多机器根本跑不起来。SSM全家桶(Spring 5.x加MyBatis 3.x)在这些老环境里跑得非常稳,不用为了环境折腾半天。
第三,企业里存量SSM项目其实很多。很多中小型企业的业务系统是十年前建的,到现在还在维护和迭代,能看懂SSM项目的人在这些团队里非常吃香。学一套SSM不是倒退,反而是给自己多留一条路。而且在简历上写“独立完成SSM项目”和“用过Spring Boot自动配置”,面试官问起来的深度完全不一样。
2.2 分层架构与包结构说明
这个项目的包结构,我按SSM社区最常见的方式组织,没有搞花活,好处是别人接手你的代码时不需要额外解释:
com.clinic ├── controller // 控制层:接收请求、参数校验、返回页面或JSON ├── service // 业务接口层:定义业务方法 │ └── impl // 业务实现层:事务边界在这里 ├── mapper // MyBatis Mapper接口,配合XML或注解SQL ├── entity // 数据库实体类(POJO) ├── dto // 数据传输对象:查询条件、表单封装 ├── vo // 视图对象:给前端页面用的数据模型 ├── interceptor // 登录拦截器、权限拦截器 ├── common // 通用工具类、统一返回结构、常量 └── config // Spring配置类:数据源、事务、MyBatis这套分层的核心原则是依赖自上而下单向流动:Controller依赖Service,Service依赖Mapper,实体在各层之间穿梭,但各层之间不能互相绕圈。Controller层不写SQL、不碰SqlSession,Mapper层不做业务判断,Service层不处理HTTP请求。刚开始写的时候可能会觉得多写很多接口很繁琐,但项目一复杂,你就会发现这种约束是保护你的——改一处逻辑不会牵一发动全身。
Spring配置上,我用了XML和JavaConfig混合的方式:数据源、事务管理用XML注入,因为这类配置稳定且好排查;业务Bean的@Service、控制器的@Controller、自动注入的@Autowired用注解,减少样板代码。MyBatis的Mapper扫描配置要特别注意,basePackage必须指向mapper包,否则会话工厂创建成功但所有SQL都执行不了,报错还是那种很难定位的Invalid bound statement (not found),新人第一次遇到基本都要卡半天。
3. 数据库设计:把“时间”当作一等公民
3.1 表结构整体规划
数据库设计是预约管理系统最重要的环节,没有之一。代码写得再漂亮,表结构设计不合理,业务跑起来照样千疮百孔。我设计表结构的时候遵循一个核心思路:把时间片当作资源来建模。
对预约业务来说,真正的底层资源是“医生在某天的某个时段”。围绕这个资源,我设计了五张核心表:用户表user、医生表doctor、排班表schedule、预约表appointment、时段字典表time_slot。辅助表还有诊所配置表、操作日志表。
实体关系上,医生和用户是一对一关联(一个医生账号对应一个医生档案),医生和排班是一对多,排班和预约是一对多。time_slot是一张很有价值的字典表,它定义了一天有哪些可预约的时段标准值,比如09:00-09:30、09:30-10:00这种固定切片。把时段抽成字典表,而不是在业务表里写死字符串,是为了统计和排序方便——数据库对字符串排序是按ASCII码排的,10:00会排到09:00前面,如果业务里直接把时段存成字符串,处理“上午的时段列表”这种查询时逻辑会非常别扭。
3.2 核心业务表逐张拆解
先看用户表和医生表。
user表:id主键,username唯一,password存加密后的密码,role区分管理员、医生、前台三种角色,还有real_name真实姓名、phone手机号、create_time创建时间、status启用状态。密码用MD5加盐存储,登录校验在Service层完成,表结构本身不涉及敏感业务字段。
doctor表:id主键,user_id关联用户表,name医生姓名,department科室(牙科诊所一般分种植、正畸、修复、牙周),title职称,introduction简介,avatar头像路径,sort_order用于管理端排序,status是否在岗。这张表是医生信息的主数据来源,排班和预约都通过doctor_id引用它。
然后是核心的排班表schedule,我设计的字段是:
id:主键doctor_id:医生IDwork_date:出诊日期start_time:开始时间end_time:结束时间max_count:该时间段最大预约数status:0正常、1停诊create_time:创建时间
这张表保存的是“医生某天在某个时间段的出诊安排”,比如张医生2025年6月10日09:00到12:00出诊,最多接待12个患者。为什么需要max_count?因为牙科的不同项目耗时不一样,正畸复诊可能只要5分钟,种植手术要40分钟,所以不同排班的最大预约数不能写死,要由管理员根据项目类型灵活设置。
排班表后面接的是预约表appointment,字段包括:
id:主键appointment_no:预约单号,唯一patient_name:患者姓名patient_phone:患者电话doctor_id:医生IDschedule_id:关联排班IDappoint_date:预约日期start_time:预约开始时间end_time:预约结束时间time_slot:时段标识,如09:00-09:30status:0待就诊、1已完成、2已取消、3已爽约source:预约来源,电话还是前台remark:备注create_time、update_time:创建和更新时间
这里有个设计取舍我要解释一下:患者信息直接冗余到预约表里,而不是单独建患者表外加关联,是因为在小诊所场景里同一个患者可能会多次到访,但是否建档、档案是否完整,取决于诊所的管理深度,不是一个简单的用户系统能覆盖的。预约表冗余患者姓名和电话,查询不依赖额外JOIN,对于预约管理来说完全够用。如果你后面想做得更规范,可以增加一张patient表,预约表用patient_id关联,代价是数据模型多一层、查询多一个JOIN。我权衡之后选择了冗余,这是典型的业务取舍——在小规模系统里,查询性能和维护简单往往比严格的三范式更重要。
3.3 关键索引与数据一致性约束
索引设计是预约系统性能和数据一致性的双重保障。我在这些字段上建了索引:
appointment(doctor_id, appoint_date, start_time)联合索引:这是最频繁的查询路径,查某个医生某天某时段的预约情况,也就是冲突检测查询,必须走索引。appointment(appoint_date, status):用于前台查某天所有待就诊预约。schedule(doctor_id, work_date):查医生某天的排班。appointment(appointment_no)唯一索引:保证单号不重复,防止前端重复提交时造成数据混乱。
数据一致性上,除了代码层面的锁和事务,数据库层面我用了唯一索引兜底。针对“同一医生同一日期同一开始时间不能有两个有效预约”这个硬规则,我一开始想直接用唯一索引,但后来发现不行——因为状态字段会变,唯一索引没法直接表达“有效”这个条件。如果就诊完成后状态变成1,同一个时段理论上可以被新预约复用,唯一索引就会挡住这种合理场景。更稳妥的方案是代码层用SELECT ... FOR UPDATE锁行加事务保证,下面实现部分会详细讲。
另外,字段类型定义必须严格。时间字段统一用DATETIME,不要有的用DATE有的用VARCHAR,否则Java端的LocalDateTime和java.util.Date转换会浪费你半天时间。状态字段我用INT加注释,不用VARCHAR存中文,因为数字枚举值更好做扩展和统计——以后要加“待支付”“已退款”这类状态时,数字枚举比字符串重构成本低得多。
4. 核心业务流程与关键实现
4.1 排班管理模块的实现思路
排班模块是预约系统的“源头”——没有排班,就没有可预约的号源。管理员进入排班页面,选择医生、日期、时段,提交后系统生成一条schedule记录,同时该医生在这一天这个时段就有了可预约的容量。
排班设计里最实用的业务细节是重复排班。诊所不是只排一天,而是每周都要排。我实现了“周排班模板”的功能:管理员选择周一到周日里哪几天出诊、每天几个时段,一键生成未来一周的排班记录。这个逻辑本身不复杂,就是循环插入,但要注意排班冲突校验——同一医生同一日期已经有了排班,就不能再生成重复记录。实现上可以用schedule(doctor_id, work_date)的唯一索引兜底,插入前先查一次,插入时靠数据库唯一性约束把并发下的重复数据挡住,只要有一个索引在,这个问题就不会漏。
停诊处理是这个模块里最体现经验的地方。医生临时停诊,不能直接删掉排班记录,因为可能有患者已经约了这个时间段。正确做法是把schedule的状态置为停诊,同时把所有关联的appointment状态从“待就诊”改成“已取消”,取消原因标记为医生停诊。这两个操作必须在同一个事务里完成,否则会出现排班已停但预约还是待就诊的数据不一致。通知患者的动作可以放在事务提交后异步执行,Spring里用@Async或者手动提交一个线程池任务都行,核心原则是不能阻塞主流程。
4.2 预约流程的状态机设计
预约状态机的设计,是这个项目里最值得拿出来讲的部分。我定义了4个核心状态:0-待就诊、1-已完成、2-已取消、3-已爽约。状态迁移路径是这样的:
- 待就诊转为已完成:正常流程,患者到诊,前台核销。
- 待就诊转为已取消:患者在预约时间前取消,或医生停诊导致系统自动取消。
- 待就诊转为已爽约:过了预约时间患者未到且未提前取消,系统定时任务自动转状态。
- 已完成、已取消、已爽约都是终态,不参与其他状态迁移。
状态迁移的实现我用了一个非常朴素但极其好用的技巧:带状态的UPDATE条件。比如核销操作,执行的SQL是:
UPDATE appointment SET status = 1, update_time = NOW() WHERE id = #{id} AND status = 0这条SQL的返回值是受影响的行数,如果返回0,说明这条记录当前的状态已经不是待就诊,那这次核销操作就是无效操作,业务层直接抛异常提示“该预约状态已变更,请刷新后重试”。这个技巧避免了“先查再改”的竞态窗口,在多个前台同时操作的场景下特别好用。改约操作同理:先取消原预约,再创建新预约,两步必须包在同一个事务里,任何一步失败都要整体回滚。
爽约状态不靠用户端主动操作,而是靠定时任务。项目里我用Spring的@Scheduled注解,每天凌晨跑一次,把“预约日期小于今天、且状态仍为待就诊”的记录批量更新为爽约。定时任务要控制执行时间和频率,尽量避开业务高峰,同时做好幂等处理——同一批数据重复扫描不会产生脏数据,更新条件加上status = 0就天然幂等了。
4.3 冲突检测:防止重复预约的关键
这一节是整个预约系统最核心的技术点,也是我踩坑最深的地方。
先交代背景。项目上线第一天,两个前台同时接到两个电话,都约张医生下周三上午10:00的号。数据库里当时没有这个时段的数据,两个事务同时做“查询该时段是否有预约”的操作,都返回空,然后同时插入预约记录,结果就出现了一个时段两个患者的脏数据。患者来的时候才发现重了,现场直接乱成一锅粥。
这个问题的本质是并发下的读改写竞态。解决办法有好几种,我按从简单到复杂排一下。
方案一:数据库唯一索引兜底。把(doctor_id, appoint_date, start_time)建成唯一索引,当两个并发事务同时插入时,第二个会抛唯一键冲突异常,业务层捕获后提示“该时段已被预约”。优点是对性能影响极小,缺点是前面说过的状态语义问题——同一天同一个时段,如果旧预约取消了,新预约要复用,唯一索引会挡住这种合理业务。我在这个项目里没有用它做主防线,但建议在appointment_no上建唯一索引防止重复提交。
方案二:SELECT ... FOR UPDATE悲观锁。事务里先执行查询把目标时间段的行锁住,再判断有没有有效预约。这个方法的问题在于,如果查不到目标行,FOR UPDATE锁不住空数据。MySQL的InnoDB在特定条件下通过索引间隙锁能覆盖部分空场景,但依赖隔离级别和索引结构,说不清楚的地方很多,我不建议新手在这个方案上死磕。
方案三:事务加FOR UPDATE的联合索引查询,这是我在项目中实际采用的主防线。核心流程是:
- 开启事务;
- 查询该医生该日期该时段中状态为“待就诊”或“已完成”的预约数量;
- 如果数量大于0,抛出业务异常“该时段已被预约”;
- 如果数量等于0,插入预约记录;
- 提交事务。
这个方案第2步的查询SQL是关键:
SELECT COUNT(*) FROM appointment WHERE doctor_id = #{doctorId} AND appoint_date = #{appointDate} AND start_time = #{startTime} AND status IN (0, 1) FOR UPDATE当这条查询走了(doctor_id, appoint_date, start_time)联合索引时,InnoDB会对索引范围内的间隙加排他锁。两个并发事务同时执行这条SQL,第二个会被阻塞,直到第一个提交或回滚,从而把竞态窗口堵死。这是我实测过多轮、确认有效的方案,比单纯“查再插”稳得多。不过要提醒一句:使用FOR UPDATE一定要让查询走联合索引,否则锁的是全表,并发一高性能直接崩掉。
实际开发中,我把冲突检测封装成一个独立的Service方法,方法上标注@Transactional,保证检测和插入在同一个事务里完成。事务隔离级别用MySQL默认的REPEATABLE READ,没有特意改成READ COMMITTED,因为FOR UPDATE在当前方案里已经能保证并发正确性,不需要为了降低锁范围而牺牲隔离级别的一致性承诺。
4.4 提醒通知与爽约处理
预约系统如果不做提醒,爽约率会高得离谱。我在项目里接了一个支持HTTP API的短信平台,在预约创建成功时发送“预约成功”短信,在预约前一天的晚上8点批量发送“就诊提醒”短信。
发送短信的时机有个细节:成功短信必须在事务提交之后发,绝不能放在事务里。否则一条慢短信会拖住整个数据库事务,高峰期数据库连接会被占满,系统直接卡死。我用Spring的@TransactionalEventListener监听事务提交事件,事务提交完成后异步发短信。就诊提醒则采用定时任务,每天20点扫描第二天的预约记录,按手机号去重后逐条发送。短信发送要记录日志,发送失败的标记重试,重试两次还失败就人工介入,避免患者投诉说没收到提醒。
爽约的处理逻辑前面状态机里已经提了,这里补一个业务细节:连续爽约3次的患者,前台的预约界面要给出醒目提示。我在patient_phone上做统计查询,每次创建预约前检查该手机号近90天的爽约次数,超过阈值就弹窗提示前台“该患者爽约频繁,请确认后继续”。这个功能不阻断预约,但让前台有信息做判断,既照顾患者体验,又保护医生利益。
5. 权限控制与登录方案
5.1 三角色权限模型
权限模型不复杂,但边界一定要清晰。用户在user表的role字段区分角色,取值就三个:ADMIN、DOCTOR、RECEPTIONIST。
- 管理员:拥有全部权限,包括医生管理、排班管理、全局统计、系统配置、操作日志查看。
- 医生:只能查看自己的排班、自己的预约列表、自己的工作统计,不能操作其他医生的数据。
- 前台:可以查询所有医生的排班和号源,创建、取消、核销预约,但不能修改医生信息、不能查看全局统计报表。
权限控制落在两个层面。菜单层面,根据角色渲染不同的前端菜单;接口层面,所有Controller的请求经过拦截器校验角色。只做菜单隐藏而不做接口鉴权,绕开页面直接调接口就能越权,这是很多半成品项目的通病,招个会用Postman的人就能把你系统打穿。
医生只能看自己数据这一点,我通过Service层做强制隔离:查询医生的排班和预约时,doctor_id不从前端参数取,而是从登录用户的Session里取,前端传什么参数都不认。这样做的好处是,即使有人恶意调用接口,也拿不到其他医生的数据。这个设计不是额外的工作量,而是权限模型的一部分,必须在写代码的时候就内化进去。
5.2 登录态与鉴权实现
登录模块用的是传统Session方案,没有引入Spring Security。为什么?Spring Security在这个项目里属于重武器,配置成本和概念负担都比较大,而三角色权限模型用拦截器就能干净地解决,没必要为了“规范”引入一套复杂的框架,增加新手的学习成本。
具体实现分三步。自定义一个AuthInterceptor,在SpringMVC的配置里注册,拦截所有以/api/开头的请求。拦截器里做三件事:第一,从Session中取登录用户,如果为空,返回未登录的JSON提示或重定向到登录页;第二,取出当前请求的Controller方法上的自定义注解@RequireRole,注解里声明了允许访问的角色;第三,如果当前用户的角色不在注解允许的列表里,返回无权限的JSON提示。
@RequireRole是我自定义的注解,用法类似@PreAuthorize,但更轻量:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }在Controller方法上这样使用:
@RequireRole({"ADMIN"}) @GetMapping("/schedule/create") public String createSchedule(...) { // 只有管理员能进入 return "schedule/create"; }登录密码用MD5加盐存储,盐值用用户名加固定字符串拼出来,登录成功后把用户ID、角色、姓名写入Session,由拦截器统一取用。登出就是session.invalidate(),操作日志里记录登出行为。这个方案虽然朴素,但对于管理后台类系统完全够用,而且逻辑非常透明,出了问题好排查。比直接用Spring Security维护一堆Filter和配置,对新手友好得多。
另外,登录页面做了验证码,用Java生成的随机图片验证码,防止有人写脚本暴力猜密码。Session超时时间设置成30分钟,前台挂机时间长了自动踢下线,重新登录后回到首页,这个体验细节诊所反馈很满意。
6. 常见问题排查与避坑实录
6.1 高频问题速查表
这个项目开发过程中遇到的技术问题,很多都是SSM老项目的高频经典场景。我把它们整理成一个速查表,按“症状、原因、解法”的结构写,你以后遇到类似问题可以直接对号入座:
| 症状 | 根本原因 | 解决方案 |
|---|---|---|
Invalid bound statement (not found) | Mapper接口和XML的namespace不匹配,或mapper-locations配置错误 | 检查XML的namespace是否等于Mapper接口全限定名,检查SqlSessionFactory的mapperLocations路径 |
| 日期参数查询不出数据 | 前端传的是String,数据库是DATETIME,格式不匹配 | 统一配置@DateTimeFormat或全局类型转换器,前端统一传yyyy-MM-dd HH:mm:ss |
| 同一时段出现两条预约 | 并发读改写竞态 | 联合索引加SELECT ... FOR UPDATE,事务保证检测和插入原子性 |
| 修改排班后前端不刷新 | 返回类型和缓存处理不统一 | 确认Controller方法统一加@ResponseBody,检查Ajax请求的缓存头设置 |
Tomcat启动报ClassNotFound | 依赖冲突,常见javax.servlet和jakarta.servlet并存 | 用mvn dependency:tree排查依赖版本,排除冲突的传递依赖 |
| 中文乱码 | 请求和响应编码不一致 | web.xml里配置CharacterEncodingFilter,统一UTF-8,数据库连接URL加characterEncoding=utf8 |
这张表里的问题,我基本都真实遇到过。尤其是Invalid bound statement,几乎每个SSM新手都会踩一遍,排查方向就两个:一是XML文件有没有被Maven打包进target/classes,二是namespace和Mapper接口的全限定名是否完全一致。这两个方向查完,90%的问题都能解决。
6.2 并发重复预约问题的实战复盘
再复盘一次项目上线当天遇到的那个并发重复预约问题,因为这能帮你理解数据库锁的实际行为,比单纯看概念强得多。
当时的情况是诊所开业第一天,前台两个工位同时操作,一台电脑登录一个账号,两人同时看到张医生上午10点的时段显示“可约”,几乎同时点了确认。系统里出现了两条预约记录,都是待就诊状态。
我当时的排查流程是这样的:先查数据库,确认两条记录确实都存在,状态都是0。然后查看操作日志,发现两条记录的创建时间只差了不到200毫秒。再分析代码,问题出在创建预约的方法里——我先查了该时段的预约数量,再进行插入,但这两个操作之间没有事务包裹,也没有任何锁,另一个线程完全可以在“查询返回空”之后、“插入提交”之前抢先插入一条记录,而我这个线程不知道,因为插入时没有做唯一性兜底。
修复分两步走。第一步,把检测和插入包进同一个@Transactional事务;第二步,把冲突检测SQL加上FOR UPDATE,配合联合索引让InnoDB在间隙上加锁。修完后又做了并发压测,用两个线程池同时对同一时段发起预约,反复跑了20轮,没有再出现重复预约。压测的时候还特意观察了SHOW ENGINE INNODB STATUS里面的锁等待信息,确认锁是落在索引间隙上而不是全表锁。
这里有一个更底层的思考:为什么不能只靠表上的唯一索引直接解决?因为唯一索引约束的是“字段值组合不能有重复”,而业务规则要求的是“同时间段下不能有两个有效预约”。如果一条预约取消了,状态变成2或3,这个时段实际上可以被新预约占用,但唯一索引不知道这个业务语义,它只看字段值。所以,业务锁、事务、索引这三层要配合使用,少了任何一层,系统都可能在某些边界场景下出问题。这就是为什么我在数据库设计那一节反复强调,约束和索引要结合业务语义来设计,不能想当然。
6.3 项目后续可以怎么扩展
这套SSM预约系统跑起来之后,后续可以扩展的方向其实很明确。我挑几个性价比高的说说。
一是把患者档案做完整。现在是预约表里冗余了患者姓名和电话,扩展时可以增加patient表,把患者历次就诊记录、病史、X光片关联起来,变成一个真正的小型患者管理系统。牙科诊所的复购率很高,患者档案完善之后,可以按项目类型和医生偏好做复诊提醒,这是诊所非常需要的功能。
二是增加自动化的报表统计。现在统计报表是前端直接查库实时计算,数据量大了之后会慢。可以引入定时任务,每天凌晨把前一天的数据聚合到report_daily表,报表页面直接查聚合结果,速度会快很多。报表维度至少包括:每日预约量、医生工作量、爽约率、项目类型分布,这些数据对诊所的经营决策价值很大。
三是考虑把SSM项目整体演进到Spring Boot加MyBatis Plus。这个演进不是推翻重来,而是换一层壳:Service和Mapper层代码基本可以原样保留,Controller的注解映射方式也大体一致,真正要改的是依赖管理和自动配置。如果你以后面试遇到“做过SSM的老项目怎么升级”,提前记录一次实际的升级过程,是非常好的面试素材,比背八股文强得多。
我在实际开发中的个人体会是,做这类管理系统项目,技术本身不是最大的难点,难点在于把业务流程理解透。你跟着这个项目把排班、预约、改约、核销这条完整的业务链路走通,SSM框架的用法、数据库事务和锁的原理、Spring的AOP和定时任务机制,都会有很具体的体感,而不是停留在概念层面。以后再遇到更复杂的系统,不管是业务建模还是并发处理,思路都会清晰很多。