接手这个项目的时候,老板就一句话:诊所前台每天都在接电话、翻纸本子登记预约,乱得不行,你搞一套预约管理的东西出来。预算有限、工期一个月,而且诊所里那台老电脑跑不了太新的东西——这基本就把技术栈钉死了:Java、SSM、MySQL 5.7,外加一个Tomcat 8.5。也就是标题里那个java_ssm63牙科诊所项目预约管理系统的由来。
先说这套系统能做什么。患者可以在网页上按医生、按日期查看可预约时段,自助预约和取消;前台能维护排班、处理到诊与改约;医生能看自己一天的号源列表。整个流程覆盖预约管理的完整闭环。如果你是刚学完Java基础想找实战项目练手的同学,或者是准备应付SSM面试题的老哥,这篇文章应该能让你少走不少弯路。下面我会从选型、业务模型、数据库设计到并发踩坑完整讲一遍,全是实际操作里摸出来的东西,不是照抄文档的教程。
1. 为什么是SSM而不是Spring Boot:慢技术背后的实战理由
1.1 为什么不用Spring Boot:配置可见性才是关键
现在绝大多数Java项目起步就是Spring Boot,命令行一跑,一个内嵌Tomcat就起来了。但牙科诊所这类小型管理系统有个特点:运行环境不稳定,可能是一台老电脑,用户量不大但配置项很杂,项目做完之后大概率还要交给别人维护。Spring Boot把自动配置全封装了,遇到"某个配置为什么会生效"这种问题,你得顺着自动配置类一层一层往下翻,反而比SSM直接写配置更难排查。
SSM三个字母拆开看:Spring管对象和事务,SpringMVC管请求和分发,MyBatis管SQL和数据库,分工非常清楚。用SSM的时候,你要亲手配置web.xml、spring-mvc.xml、spring-mybatis.xml,配置DataSource时得自己写JDBC驱动、连接URL、密码,配置SqlSessionFactory时要指定mapperLocation。这套流程看起来繁琐,但它逼着我把Spring容器的装配逻辑、MyBatis的SqlSession从哪里来这些问题全部弄明白了。
1.2 SSM常用注解和SpringMVC流程,写代码时都在手边
开发过程中,SSM常用注解基本就是固定的那一套:@Service标注Service层,@Controller加@RequestMapping标注接口路径,@Autowired做依赖注入,@Transactional控制事务。做一段时间之后你会明显发现,Spring的IoC和AOP不再是面试题,而是每天都在用的工具。比如@Transactional,你注解到方法上,Spring会用代理对象给方法加事务切面,哪个Service方法出问题就自动回滚,"数据一致性"这个词从抽象概念变成了具体可执行的行为。
SpringMVC的执行流程在这个项目里体现得也很直观。请求进来先到DispatcherServlet,它通过HandlerMapping找到对应的Controller方法,再经HandlerAdapter调用,Controller里返回视图名或@ResponseBody数据。我处理预约列表的分页用的就是Controller返回JSON、前端负责渲染的方式。这套流程理清之后,再看其他Web框架会有一种眼前一亮的感觉——Spring Boot帮你做掉的那些事,底下走的路其实完全一样。
2. 业务模型拆解:预约不是往表里插一条记录那么简单
2.1 牙科预约与普通挂号的三点本质区别
很多人一听预约系统就觉得简单:患者选医生、选时间、点预约,插一条记录不就完事了吗?真做一个牙科版本你才会发现,事情远没有这么简单。
第一个区别是治疗周期长。牙科不像内科开药就走,补牙、根管治疗、正畸基本都要来好几次。正畸患者往往一两周就来一次,一治就是一年。这决定了系统必须支持复诊预约:医生在治疗结束后直接在系统里帮患者约下一次的时间,而不是让患者重新去网上抢号。否则患者每次来都要重新排队,体验会非常差。
第二个区别是资源模型不同。综合医院的挂号资源是医生加时间段,牙科诊所还要加一个椅位的概念。一把牙椅就是一个独立工作位,一个医生在同一时段只能占一把椅。如果诊所规模小,两把椅三个医生,排班时就得考虑哪个医生用哪把椅。这个细节在设计数据库时很容易被忽略,等到现场用起来才发现椅子撞车,就很麻烦。
第三个区别是时段粒度不一样。洗牙30分钟,根管治疗可能60到90分钟,拔智齿还要预留处理突发情况的时间。所以排班不能做成统一30分钟一刀切,要给不同诊疗项目预留不同时长。我在schedule表里专门加了一个duration字段来控制每个时段可放的号量,后面数据库设计那部分会展开讲。
2.2 三种角色和一套拦截器
系统里分三种角色。管理员对应诊所老板或前台,负责维护医生信息、做排班、看报表;医生能查看自己的排班和患者预约名单,还能发起复诊预约;患者注册登录后在线预约、取消预约、查看历史记录。
权限这块我没上Spring Security,用SpringMVC的拦截器就足够了。按路径前缀拦截:/admin/**要管理员,/doctor/**要医生,/patient/**要登录患者。拦截器里判断Session中的role字段,不满足就302到登录页。这里有一个体验细节:拦截未登录用户时,我会把目标地址存进Session,登录成功之后跳回原页面,否则患者每次登录完都会落在首页,还要重新点一遍菜单。这个小改动很不起眼,但被前台护士表扬过好几次。
2.3 预约状态机:五个状态的流转设计
预约记录不只是有或没有两种状态。我设计的流转是已预约、已到诊、已完成、已取消、爽约五种。患者提交成功就是已预约;到店后在系统里确认到诊;诊疗结束由医生标为已完成;在约定时间前取消就是已取消;预约了没来也没提前取消就算爽约。
这个状态机在统计报表里作用很大。比如算爽约率,只需要按患者和状态分组统计就能得到;诊所要考核医生工作量时,按医生和已完成数量统计也很方便。我在代码里定义了一个AppointmentStatus常量类,把状态码统一写清楚,避免项目里到处冒魔法数字。后来增加功能时,只要打开这个常量类,状态命名就一目了然,排查问题的效率也高了不少。
3. 数据库设计:六张核心表与一个关键的防并发约束
3.1 表结构概览:六张核心表怎么分工
我把整个项目拆成了六张核心表:sys_user用户表、doctor_info医生信息表、schedule排班表、appointment预约表、treatment_item诊疗项目表,外加一张operation_log操作日志表。其中sys_user表用一个role字段区分管理员、医生、患者三类账号,权限拦截时一个Session里的角色字段就能搞定。treatment_item表存的是洗牙、补牙、根管治疗等项目,排班页的时段模板就是从这张表里选出来的。
以schedule表为例,核心字段是这样的:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| doctor_id | int | 关联医生 |
| work_date | date | 排班日期 |
| start_time | datetime | 时段开始 |
| end_time | datetime | 时段结束 |
| duration | int | 计划时长(分钟) |
| status | tinyint | 0未预约 1已占用 |
| version | int | 乐观锁字段 |
关键设计是:schedule表里每一行代表一个具体的可预约号源,预约发生时就锁定对应行的status。号源不是"动态计算出来的",而是"排班时就提前生成好的"。这样做的好处是前端展示时段列表时直接查这张表,不需要在业务层再去推时间段,逻辑简单得多。
排班数据量其实不少。一个医生一周排7天、每天拆十几个时段,就是几十条记录,几个医生下来一周就是上百条。所以专门写了一个批量生成排班的方法,入参是医生ID、日期范围、时段模板List,一次性向schedule表插入多行,前台不需要一天一天地手动点。这个功能上线后,前台护士排班的操作时间从半小时缩短到两分钟。
3.2 唯一索引:防超卖的最后一道防线
这是整个数据库设计里最重要的一条。同一医生同一时段只能有一个患者预约,所以schedule表上加了唯一索引UNIQUE KEY uk_schedule (doctor_id, work_date, start_time),防止排班数据本身出现重复。这个约束直接排除了"同一个医生同一天同一时段排了两遍"这种低级错误。
预约侧的逻辑也类似。appointment表存的是doctor_id、work_date、start_time三列的组合,同样建了唯一索引。为什么一定要建?因为一旦两个请求并发同时预约,业务层的判断会在时间窗口里失守,两个请求都认为自己抢到了号,都会去插入预约记录。数据库的唯一索引就是最后一道防线——第二个请求插入时,MySQL会直接抛Duplicate entry异常,程序捕获到这个异常,就能告诉用户"这个号已经被别人抢先了"。
顺便提一句,appointment表上doctor_id和patient_id也分别建了普通索引。系统里最高频的查询就是"某医生某天的所有预约"和"某患者的全部预约记录",这两个索引能把查询时间从几百毫秒压到个位数毫秒。数据量不大时不用分库分表,但索引一定要建对,否则后面越用越卡。
3.3 事务边界:哪些操作必须同生共死
预约、扣减号源、记录日志这组操作必须保证要么全成功、要么全失败。我用Spring的@Transactional注解,默认传播行为REQUIRED就够了:外层方法开启事务,里面所有数据库操作都在同一个事务里。这里踩过一个Spring代理失效的坑,具体放在第5章详细讲。先记住一点——要让事务注解生效,必须调用的是Spring容器管理对象的代理方法,同一个类里通过this调用是没用的。
4. 主链路实现:排班、预约、改约、取消的代码要点
4.1 排班模块:先删后插的批量生成逻辑
排班模块的页面是选医生、选日期范围、选每天的时段模板,提交到Service层然后批量生成。核心逻辑分两步:先删掉这个医生在目标日期范围内已有的schedule,防止重复;再按选中的模板批量插入。为什么先删再插而不是逐条判断?因为诊所排班通常是直接重排整周,增量更新的场景很少,全删全插最省心,加上有唯一索引兜底,重复数据根本插不进去。
Controller层用@RequestParam接收一个List ,SpringMVC会自动把页面提交的多个时段绑定成List。这个地方有个容易踩的坑:如果前端没传该参数,@RequestParam默认会报400错误,所以需要设置required = false,同时另传一个布尔参数表明"本日无排班"。我把这个细节写进了项目文档,因为确实被实习生问过好几次。
4.2 预约接口:五步校验链与条件UPDATE
预约接口是系统的心脏,也是最容易出bug的地方。我把它拆成五步:参数校验,医生ID、日期、时段不能为空;号源检查,查schedule的status必须为0;防重复预约,同一个患者在同一天、同一医生下不能有两条有效预约;状态占用,把schedule对应行的status置为1;最后插入预约记录,状态为已预约。五步全放在一个事务方法里。
最关键的是第2步和第4步之间有一个时间窗口,两个线程同时进来会都读到status为0,然后再都执行更新和插入。所以实际代码里我没有走"先查后改"的老路,而是直接用条件UPDATE:
int updated = scheduleMapper.occupy(scheduleId); if (updated == 0) { throw new BusinessException("该时段已被预约,请重新选择"); }对应的Mapper SQL是:
UPDATE schedule SET status = 1 WHERE id = #{scheduleId} AND status = 0这条SQL自己完成了"判断加占用",影响行数为1才算抢到号,为0就直接返回已被预约。数据库行锁天然挡住并发,代码也简洁,这是整个项目里我最满意的一处实现。
4.3 取消与改约:一个事务边界引发的思考
取消预约相对简单:把appointment状态置为已取消,同时把schedule对应行还原成status = 0。两个操作单独看都很简单,但必须放进同一个事务。不然的话,如果取消成功、号源还原失败,这个号位就永远显示已占用,后面再也没人能约上。
改约的逻辑更有意思。改约的本质是取消旧预约加新增新预约。我原本打算两个方法分开写,后来一想不对:如果取消成功了,新预约因为号源冲突失败了,那患者就被白白取消了一次,体验极差。所以改约必须把两步包进同一个事务,并且新增预约时要重新走一遍完整的预约校验。我的做法是把预约核心逻辑抽到AppointmentService类里,改约方法注入这个Service来调用,这样既复用了校验逻辑,又绕开了在同一类内部通过this调用导致事务注解失效的问题。
5. 一次并发超卖事故:排查过程与数据一致性修复
5.1 事故现场:两个患者都显示预约成功
系统上线第三周的某个晚上,前台发来一条消息:同一个医生、同一天的9:30到10:00时段,系统里显示有两条预约记录,患者还是两个不同的人。前台一口咬定没人重复录入,说这两个患者当时就在诊所,用两台手机同时预约,都显示成功了,护士在后台也看到了两条预约。
我当时的头嗡了一下。这种问题看似小概率,一旦出现就是信誉问题。患者约好了来了,结果医生时间被排重了,要么让患者等,要么让人白跑一趟。当天晚上我就一定要把根因找出来。
5.2 排查链路:四个步骤锁定根因
我的排查顺序是这样的。第一步先查数据库,appointment表里确实有两条记录,doctor_id、work_date、start_time完全一样,status都是1。说明确实插入了两条,不是页面显示问题。第二步写脚本复现,用两个线程同时请求预约接口,稳定复现,两个请求返回的都是预约成功。第三步看日志,两个请求几乎同时进入Service方法,都在SELECT查询时读到schedule.status为0,然后各自执行了更新和插入逻辑。第四步确认根因,问题就出在"先查后插"这个流程上:业务层的SELECT检查和数据库的INSERT之间存在时间窗口,两个并发请求都能通过检查,接着都去执行写入。
这里要插一句,MySQL默认的事务隔离级别是REPEATABLE READ。在这个隔离级别下,两个事务同时启动,SELECT出来的快照是一样的。A事务查到时status为0,B事务查到的也是0,这就是典型的并发超卖场景。面试被问到"java怎么保证数据一致性"的时候,这个案例就是最有说服力的回答。
5.3 修复方案:三层防线的具体落地
我做了三层修复。第一层是数据库层加唯一索引,appointment表加UNIQUE(doctor_id, work_date, start_time),让数据库从物理层面拒绝重复预约。第二层是把占用号源从先查后插改成条件UPDATE,核心就是上面那段SQL。第三层是业务层对同一患者同一时段加缓存去重,防止单个用户手滑连点多次。数据正确性的底线在前两层,第三层只是优化体验。
修完之后我又压测了一轮,五个线程同时抢同一个时段,结果永远是只有一个成功,四个返回失败。看到这个结果,我才彻底安心。
提示:唯一索引和条件UPDATE这两个方案一定要同时上。条件UPDATE解决并发抢号的写入冲突,唯一索引兜底极端情况下仍然插入重复记录的可能。少一层都不安心。
5.4 为什么不用select for update:悲观锁的三个问题
有同事问我,为什么不直接用SELECT ... FOR UPDATE加悲观锁?我试过,能用,但有几处不舒服。第一,FOR UPDATE依赖索引,如果查询条件没有命中索引,MySQL会升级成表锁,整个排班表都被锁住,其他患者的预约请求全部排队。第二,锁的粒度太粗,FOR UPDATE会把涉及行的读锁都占住,同一位医生当天的所有时段都会被锁上,别的患者明明可以约下午3点的号,却也要跟着排队等锁释放。条件UPDATE的方案只锁真正在写的那一行,短事务、低冲突,对这个量级的系统来说,是最理想的选择。
6. MyBatis动态SQL与时间类型:项目里踩过的三个大坑
6.1 where标签:动态SQL拼接的两个隐患
排班查询页面有医生、日期、状态好几个搜索条件,用户可能全填,也可能只填一个。这种场景下用MyBatis的动态where标签配if条件是很自然的选择。但这里有个坑:当所有条件都不填时,where标签不会生成WHERE关键字,SQL变成SELECT ... FROM schedule,没问题;可如果某个if条件判断不严谨,比如只判断了!= null而没判断!= '',前端传空字符串来的时候这个条件还是会生效,查出来的结果跟预期完全对不上。
我的统一写法是:if的test条件写成xxx != null and xxx != '',拼接时不要写任何前缀连接词,全部交给where标签去处理。另外查询条件里一定要加一个"必不为空"的条件,比如主键或者默认的日期范围限制。不然所有条件都为空时会把全表数据查出来,页面直接卡死,这种低级问题排查起来非常浪费时间。
6.2 时间字段:我用过的三种类型映射方案
时间字段是预约系统的重灾区,我至少踩过三种坑。
第一个坑是Java与MySQL的类型映射。如果把时间字段设计成java.util.Date,MyBatis会自动处理,但前端传的是"2025-01-06 09:30"这种字符串,SpringMVC默认解析不了。我最初在Controller入参上加@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm"),能解决大部分场景。后来为了统一,我把所有VO的setter里都做了格式转换,前端传字符串、直接进查询条件,都不会出问题。
第二个坑是查询某天排班时在索引列上套函数。一开始写的SQL是WHERE DATE(start_time) = #{workDate},语法没问题,但MySQL对start_time索引会失效。排班表数据一多,查询就慢。最后改成范围查询:start_time >= #{start} AND start_time < #{end},start取当天零点,end取次日零点。数据量不大时感知不明显,但作为长期维护的项目,这种SQL习惯得从第一天就养成。
第三个坑是LocalDateTime的支持。MyBatis 3.4版本之前没有内置LocalDateTime的TypeHandler,直接当参数传会报错。我项目里主链路统一用java.util.Date贯穿,报表统计的新模块则用LocalDateTime,同时给MyBatis配置了一个全局的JSR310TypeHandler,两个字段类型并存,运行正常。如果新建SSM项目,建议一上来就把TypeHandler配好,中途切换很痛苦。
6.3 批量插入:一条SQL比一百条SQL快多少
排班批量生成时,如果一条条insert,一个医生一周就是几十条SQL,前端等得直打哈欠。我改成MyBatis的foreach批量insert:
<insert id="batchInsertSchedules"> INSERT INTO schedule (doctor_id, work_date, start_time, end_time, duration, status, version) VALUES <foreach collection="list" item="item" separator=","> (#{item.doctorId}, #{item.workDate}, #{item.startTime}, #{item.endTime}, #{item.duration}, 0, 0) </foreach> </insert>这里有两个细节。第一,MySQL的单条插入报文有上限,max_allowed_packet默认可能只有4M,一次性插入太多行容易触顶。我把批次控制在100条以内,实测很稳。第二,如果排班覆盖一个多月,需要循环分批次提交,写一个for循环按100条切分即可。这个优化做完之后,生成一个医生一个月的排班从十几秒降到了一秒以内。
7. 上线后的体会与后续扩展思路
项目做完上线跑了两个多月,中间接过几次维护需求。最大的感受是:牙科诊所这类系统的技术难度真的不高,真正的难点全藏在业务细节和并发兜底里。你去看代码,每一行都很简单,但预约状态机怎么流转、号源怎么释放、并发抢号怎么兜底,这些设计决策才是项目能不能长期稳定运行的关键。
如果你打算用SSM做一个类似的预约管理系统,我最大的建议是:先把唯一索引和条件更新放在最前面做,它们就是整个系统的地基。地基稳了,后面加短信提醒、加报表统计、加小程序入口都是水到渠成的事。项目做完之后,把这套系统的源码和文档整理好,面试的时候直接拿出来讲,比背一百道java基础题都有说服力。我自己在后续迭代里加了一个简单的短信提醒功能,预约成功、临期提醒、爽约通知各一条,诊所说患者到诊率明显提升。技术不复杂,但业务价值立竿见影。