SpringBoot体检预约系统开发难点与并发控制实战 简介面向中小医院体检预约管理场景的Spring Boot开发学习资料以“人民医院体检预约系统”为完整案例覆盖患者端与后台管理端两大模块前者包含注册登录、体检列表/套餐查看预约、咨询与在线客服后者包含用户管理、体检类型、预约管理、体检报告上传、套餐类目及系统管理等。项目采用Java、Spring Boot与MySQL实现附带论文文档可帮助学习者理解业务梳理、权限划分、数据表设计与前后台功能落地。资源包共1个doc文档大小5.37MB适合毕业设计参考、课程项目复现及Spring Boot入门进阶人群。文档中已有摘要、目录、需求分析、功能设计、数据库设计与系统实现等章节可以作为从立项到编码到论文撰写的完整参考模板已有1029人学习下载尤其适合需要快速搭建类似管理系统并输出毕设文档的开发者。1. 基于SpringBoot的人民医院体检预约系统先弄明白难在哪再动手基于SpringBoot的人民医院体检预约系统第一眼看上去像一套普通的 Web 管理后台真正进入业务层后才会发现难点根本不在增删改查而是三件事号源并发不能超卖、体检状态流转不能乱、时间边界和取消规则不能含糊。最典型的一个翻车场景就是早上 8 点放号两个用户同时抢同一个上午时段的最后一个名额结果落库时写进了两笔有效预约余量显示却还是正常。下面这套做法按落地顺序整理从六张核心表的结构设计讲起之后是预约事务和行锁怎么写、三个角色怎么划分权限、体检闭环怎么串再单列一章讲我实际踩过的五个坑最后用一个热点号源缓存技巧收尾。适合正准备从零搭体检预约系统的开发者也适合手里已有项目想加固的工程师。2. 先把数据模型立住SpringBoot项目骨架与六张核心表的结构设计2.1 项目骨架与依赖选型为什么光有 Web Starter 不够先说结论体检预约系统这类偏业务的后台用 Spring Boot 做骨架没有任何悬念真正要花心思的是在依赖上多选三样持久层框架、Redis、参数校验。我这边常用的组合是 Spring Boot 2.7 LTS、Java 8/11、MyBatis-Plus 和 MySQL。选 2.7 的原因很实际医院内网的服务器和现场机器往往还在用 JDK 8硬上 Spring Boot 3 会碰到一堆兼容问题而 2.7 生命周期长、生态成熟。为什么持久层用 MyBatis-Plus 而不是 JPA体检预约里有大量自定义 SQL 场景行锁查询要写 SELECT ... FOR UPDATE余量扣减要写原子 UPDATE多条件分页要动态拼条件。MyBatis-Plus 在保留 XML SQL 的同时把单表 CRUD 和分页插件做得很顺手团队上手成本比 JPA 低。Redis 则不是可选项它承担分布式锁、验证码和热点缓存三件事。一个只靠数据库的预约系统在并发起来之后很难控制余量。pom.xml 里的核心依赖是这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies版本号这里Spring Boot 2.7.18 属于 2.7 这条线上的长期维护版本MyBatis-Plus 3.5.5 能直接支持新版分页插件。mysql-connector-java 标成 runtime 是因为我们只需要它在运行时提供驱动编译期用不到它的类。starter-validation 很容易被漏掉预约接口的入参里手机号、身份证、日期这些字段如果不在入口校验后面业务层就要写一堆防御代码而且错误提示还不友好。2.2 体检人、套餐、号源、预约四张主表的关系必须先定死数据模型是整个系统里改动成本最高的部分。我按照实际业务流程拆成了四张主表和一张日志表t_member 存体检人t_check_package 存套餐t_schedule 存每天每个时段的号源t_appointment 存预约单t_appointment_log 存状态变更日志。套餐明细单独拆成 t_package_item用来把套餐和具体检验项目做关联避免套餐表里塞一堆逗号分隔的项目编号。建表时几个关键点直接写进了 SQLCREATE TABLE t_member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_no VARCHAR(32) NOT NULL COMMENT 体检人编号, id_card VARCHAR(18) NOT NULL COMMENT 身份证号, name VARCHAR(50) NOT NULL, phone VARCHAR(20), created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_id_card (id_card) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体检人表; CREATE TABLE t_check_package ( id BIGINT PRIMARY KEY AUTO_INCREMENT, package_code VARCHAR(32) NOT NULL, package_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_package_code (package_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体检套餐表; CREATE TABLE t_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_date DATE NOT NULL, period_type VARCHAR(10) NOT NULL COMMENT AM/PM, start_time VARCHAR(8) NOT NULL, end_time VARCHAR(8) NOT NULL, total_capacity INT NOT NULL, booked_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_date_period (schedule_date, period_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体检号源表; CREATE TABLE t_appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, appointment_no VARCHAR(32) NOT NULL, member_id BIGINT NOT NULL, package_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, appointment_date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT BOOKED, cancel_time DATETIME NULL, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_member_schedule (member_id, schedule_id), KEY idx_schedule_status (schedule_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体检预约表;t_schedule 里同时放 total_capacity 和 booked_count 是有意为之。虽然 booked_count 可以实时 COUNT t_appointment 算出来但预约高峰期每条预约都要聚合一次数据库扛不住冗余一个计数器字段配合下一章要讲的原子 UPDATE是控制超卖的最直接手段。version 字段留给乐观锁方案如果不想用行锁可以在更新时校验版本号但这套代码里我们优先用行锁。t_appointment 上的 uk_member_schedule 很多人会忽略。它的作用是兜底哪怕业务代码在并发下漏判了一次数据库唯一索引也会拒绝同一个人在同一时段产生第二条预约。如果业务上允许一个人给家属同时约多个套餐那这个唯一索引要改成 (member_id, schedule_id, package_id)按实际规则来。索引 idx_schedule_status 则是给“查某时段有哪些预约、某个状态有几天”这类高频查询用的这里不建议把状态直接放进唯一索引因为状态会变化会让索引频繁分裂。2.3 application.yml 与插件配置连接池、Redis 和时区一次配好项目骨架搭好后我习惯先把基础配置写完整而不是等到联调再补。下面这份 application.yml 是精简过的可用版本spring: datasource: url: jdbc:mysql://127.0.0.1:3306/checkup_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: checkup password: changeMe hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 redis: host: 127.0.0.1 port: 6379 timeout: 2000 mybatis-plus: mapper-locations: classpath:mapper/**/*.xml global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0连接池参数要按预估并发量去设。不要一味调大 maximum-pool-size医院内网的 MySQL 通常没有很强的并发能力20 个连接配上执行时间较短的 SQL已经能支撑几百人的瞬时抢号连接池太大反而会把数据库拖垮。serverTimezone 必须显式写成 Asia/Shanghai否则晚上 8 点前端传的日期字符串和库里的 DATETIME 会差 8 小时这类问题在预约场景里特别隐蔽排查起来很像玄学。MyBatis-Plus 启动后还需要注册一个分页插件不注册的话 Page 对象会返回全量数据这是个容易被忽略的坑Configuration MapperScan(com.health.checkup.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(200L); interceptor.addInnerInterceptor(pagination); return interceptor; } }分页插件里 setMaxLimit 很实用能防止有人把 pageSize 传成 10000 直接把数据库打到慢查询。逻辑删除这里提一句体检预约涉及医疗数据我个人的习惯是不做真正的物理删除预约取消只改状态体检人信息更不应该 DELETE逻辑删除字段统一加到表里查询时 MyBatis-Plus 会自动追加条件省得每个 SQL 都手动带 deleted0。3. 号源与并发预约事务、行锁与唯一约束的一段核心代码3.1 号源怎么生成定时任务、容量配置和日期边界预约要成立前提是每天的号源已经排好了。号源生成我常用两种方式一种是管理员后台手动建排班另一种是系统每天凌晨自动生成未来两周的号源。实际项目里通常两者结合管理员负责设置容量定时任务负责批量落地。下面这段是自动生成的定时任务cron 表达式是每天 0 点 30 分执行Component public class ScheduleGenerator { private final ScheduleMapper scheduleMapper; public ScheduleGenerator(ScheduleMapper scheduleMapper) { this.scheduleMapper scheduleMapper; } Scheduled(cron 0 30 0 * * ?) public void generateNext14Days() { LocalDate start LocalDate.now().plusDays(1); LocalDate end start.plusDays(13); for (LocalDate date start; !date.isAfter(end); date date.plusDays(1)) { for (String period : new String[]{AM, PM}) { if (scheduleMapper.countByDateAndPeriod(date, period) 0) { continue; } Schedule schedule new Schedule(); schedule.setScheduleDate(date); schedule.setPeriodType(period); schedule.setTotalCapacity(80); schedule.setBookedCount(0); scheduleMapper.insert(schedule); } } } }这段代码有几个点值得细看。日期范围是从明天开始往后推 13 天意思是系统只允许预约未来 14 天内的体检。这个窗口不是拍脑袋定的体检中心的资源通常按周排放太久容易把过期的号源堆积出来。每个时段默认容量 80实际容量应该从系统配置表读取而不是写死在代码里。countByDateAndPeriod 用来做幂等防止任务重复执行时生成重复号源。后端生成号源时还应该考虑一件事周末和法定节假日的容量跟正常工作日不一样。很多医院体检中心周末只开上午下午不接。这种差异在设计时把 period_type 做成可配置枚举由管理员在排班界面手工调整特殊日期的时段而不是全靠定时任务生成。否则一到节假日用户能看到号源到了现场却发现体检中心没开门这种客诉是最尴尬的。3.2 预约下单事务、行锁、唯一约束缺一不可号源有了最关键的预约接口来了。实现预约动作我倾向直接在数据库层做并发控制代码尽量短、事务尽量快。下面是我的核心写法Service public class AppointmentService { private final AppointmentMapper appointmentMapper; private final ScheduleMapper scheduleMapper; Transactional(rollbackFor Exception.class) public String createAppointment(CreateAppointmentCmd cmd) { // 1. 锁住号源行同一时间只有一个事务能改这条 schedule Schedule schedule scheduleMapper.selectByIdForUpdate(cmd.getScheduleId()); if (schedule null) { throw new BusinessException(号源不存在); } if (schedule.getScheduleDate().isBefore(LocalDate.now())) { throw new BusinessException(不能预约过去的日期); } // 2. 在锁内校验余量 if (schedule.getBookedCount() schedule.getTotalCapacity()) { throw new BusinessException(该时段号源已约满); } // 3. 插入预约单 Appointment appointment new Appointment(); appointment.setAppointmentNo(genAppointmentNo(schedule.getScheduleDate())); appointment.setMemberId(cmd.getMemberId()); appointment.setPackageId(cmd.getPackageId()); appointment.setScheduleId(cmd.getScheduleId()); appointment.setAppointmentDate(schedule.getScheduleDate()); appointment.setStatus(BOOKED); appointmentMapper.insert(appointment); // 4. 原子扣减余量 scheduleMapper.increaseBookedCount(schedule.getId()); return appointment.getAppointmentNo(); } }selectByIdForUpdate 对应的 SQL 是 SELECT * FROM t_schedule WHERE id ? FOR UPDATE。这一行的作用是拿行锁让同一时刻只有一个事务能读取并修改这个号源。校验余量和插入预约、扣减余量都在同一个事务里锁的持有时间就是这几个数据库操作的时间。所以这个方法里绝对不能夹带远程调用、短信发送或者耗时的文件上传否则锁一久后面所有预约都堵在等待上。increaseBookedCount 的 SQL 写法也有讲究UPDATE t_schedule SET booked_count booked_count 1 WHERE id #{id}这里不要先 SELECT 回来再 UPDATE要用数据库的原子自增。即使将来有人把锁去掉这种 UPDATE 配合 WHERE booked_count total_capacity 也能挡住超卖。三层防护的顺序是事务里的行锁挡住并发原子 UPDATE 挡住极端漏网uk_member_schedule 唯一索引兜底同人重复预约。这也是我踩过坑之后才总结出来的组合少一层都容易翻车。3.3 状态机已预约、已到检、已报告、已取消和未到检预约不是生成一条记录就结束了从用户提交到体检报告出来中间要经历多个状态。我一开始只用了两个字段表示“有效/无效”后来发现医院要统计爽约率、改约率两个状态根本说不清楚。现在的系统里用 AppointmentStatus 枚举把状态定死public enum AppointmentStatus { BOOKED, // 已预约 CHECKED_IN, // 已到检 REPORTED, // 已出报告 CANCELLED, // 已取消 NO_SHOW; // 未到检 public boolean canChangeTo(AppointmentStatus target) { switch (this) { case BOOKED: return target CHECKED_IN || target CANCELLED || target NO_SHOW; case CHECKED_IN: return target REPORTED; default: return false; } } }有人会问取消和未到检看起来都能归成“没来”为什么要分开区别在于取消是用户主动行为未到检是系统在体检结束后批量标记的被动行为。医院要算爽约率就必须保留 NO_SHOW 这个状态。状态机的好处是到检核销、报告回传这些操作都在入口处先调 canChangeTo非法流转直接拒绝代码里就不会出现“已取消的预约还能出报告”这种逻辑漏洞。取消预约时还有一个麻烦已经扣掉的 booked_count 要还回去。执行还原的时候必须加上状态条件比如UPDATE t_schedule s SET s.booked_count s.booked_count - 1 WHERE s.id #{scheduleId} AND EXISTS ( SELECT 1 FROM t_appointment a WHERE a.schedule_id s.id AND a.status BOOKED AND a.id #{appointmentId} )这样写能保证只有状态还是 BOOKED 的预约才会被取消并释放号源。如果预约已经 CHECKED_IN 或者 REPORTED再执行上面的 SQL 影响行数是 0业务层据此抛出异常避免误释放已经被占用的号源。这个细节是我在联调时被测试人员逼出来的没有状态条件的 UPDATE早晚会出事故。4. 多角色权限与体检业务闭环管理员、开单医生、受检者如何协作4.1 RBAC 模型与权限注解三个角色的边界要先划清楚体检预约系统里的角色我按业务拆成管理员、开单医生、前台和受检者四类但权限边界上真正要重点做的是前三个后台角色。受检者走的是预约端登录方式是身份证号加手机验证码不走后台账号体系。后台账号体系独立成三张表t_user 存账号t_role 存角色t_user_role 存关联CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, name VARCHAR(50) NOT NULL, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表; CREATE TABLE t_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(30) NOT NULL, role_name VARCHAR(50) NOT NULL, UNIQUE KEY uk_role_code (role_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色表; CREATE TABLE t_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户角色关系表;权限控制我建议用 Spring Security 加方法级注解而不是只在前端做按钮隐藏。前端隐藏很容易被绕过直接调接口就能把状态改了。下面这种写法把权限声明放在接口上后端在真正执行业务逻辑之前就拦截RestController RequestMapping(/api/appointments) public class AppointmentController { PutMapping(/{id}/checkin) PreAuthorize(hasAnyRole(DOCTOR,ADMIN)) public ResultVoid checkIn(PathVariable Long id, RequestBody CheckinCmd cmd) { return appointmentService.checkIn(id, cmd.getOperatorId()); } PutMapping(/{id}/cancel) PreAuthorize(hasRole(DOCTOR)) public ResultVoid cancel(PathVariable Long id, RequestBody CancelCmd cmd) { return appointmentService.cancel(id, cmd.getOperatorId()); } }角色划分上管理员能碰排班、套餐、报表和所有预约记录医生和护士的主要操作是到检核销与报告回传他们不应该有权限修改套餐价格或删除号源受检者只在预约端操作自己的记录。权限点建议下沉到 Service 层入口做二次校验防止 Controller 里只校验了角色、没校验数据归属比如一个医生能不能核销另一个院区产生的预约单这个判断要在 Service 里做。4.2 到检核销与报告回传状态推进的接口怎么设计才不容易出错权限划分好了接下来是业务闭环。到检核销是预约流程里最特殊的一步它把线上的预约记录和线下的实际到场衔接起来。前端扫码枪扫到预约号后后台调用 checkin 接口状态从 BOOKED 变成 CHECKED_IN。紧接着要做的不是去改预约单本身而是去更新体检人的到场时间和指引单打印状态这些操作放在同一条事务里Transactional(rollbackFor Exception.class) public void checkIn(Long appointmentId, Long operatorId) { Appointment app appointmentMapper.selectById(appointmentId); if (app null || !app.getStatus().equals(BOOKED)) { throw new BusinessException(预约不存在或状态不允许到检); } if (!app.getAppointmentDate().equals(LocalDate.now())) { throw new BusinessException(只能在预约当天到检); } app.setStatus(CHECKED_IN); appointmentMapper.updateStatus(app); // 打印指引单、登记实到时间 checkinMapper.insert(new CheckinRecord(app.getId(), operatorId, LocalDateTime.now())); }到检校验里最容易漏的是日期判断体检预约通常不允许提前到检当天到检也要看当前时间是否在号源时段内比如上午时段 11:30 之后到检是否还能进。这个规则不应该写死在代码里而是由管理员配置“可迟到分钟数”。真实的体检中心对迟到是有宽容度的但这个宽容度要是没有系统约束就会变成前台和医生的口头协议出纠纷时说不清楚。报告回传是又一个边界。医生上传体检报告 PDF 后预约状态从 CHECKED_IN 变成 REPORTED。我这里只记录报告文档的 URL不把整个 PDF 塞进数据库。存储用院内文件服务数据库里放相对路径这样预约列表查询时不会带着大字段拖慢分页。如果体检中心还没有文件服务先用本地磁盘映射一个访问目录也能跑但后续要迁移到独立存储时相对路径的迁移成本是最低的。4.3 用事件解耦消息通知预约后、到检前、报告后三个节点怎么串体检预约系统有很多消息通知要做预约成功给受检者发短信到检前一天发提醒报告出来后发模板消息。最自然的做法是在 Service 里直接调用通知服务但体检预约的并发峰值集中在放号那一小段时间如果在主事务里发短信短信通道一慢整个预约接口都跟着超时。我一般用 Spring 事件把通知拆出去。业务主流程只负责发布事件监听器异步处理通知Component public class AppointmentNotifier { EventListener Async public void onCreated(AppointmentCreatedEvent event) { // 预约成功通知失败记录到 notify_retry 表 notifyService.sendBooked(event.getAppointmentNo()); } EventListener Async public void onReported(AppointmentReportedEvent event) { // 报告已出通知 notifyService.sendReportReady(event.getAppointmentNo()); } }事件里只放 appointmentNo 这类必要字段不要塞整个实体否则事件对象也会成为跨线程传递的负担。监听器里如果短信发送失败不能影响预约主流程的返回结果落到 notify_retry 表由定时任务重试。还有一个容易踩的坑Async 默认失效是因为没在启动类开启 EnableAsync或者监听器被同类内部调用。我第一次用的时候忘了加 EnableAsync发出去的短信全部没有执行查了半天才发现是异步注解没生效这个亏吃过一次就再也不会忘了。5. 体检预约系统避坑指南五个翻车现场与排查路径5.1 现象一同一时段号源被两人同时约中数据库余量超售现象是早上 8 点放号压测 50 个并发同时预约同一个时段的最后一个名额最后 t_schedule 的 booked_count 比 total_capacity 大了 1数据库里出现超卖。原因很直接业务代码先 SELECT 查余量再判断是否有名额最后执行 UPDATE。两个事务同时读到 booked_count79都觉得自己是第 80 个先后执行成功。解决方式就是前面第 3 章写的三重防护先 SELECT ... FOR UPDATE 锁住号源行再用 UPDATE t_schedule SET booked_count booked_count 1 WHERE id ? AND booked_count total_capacity 做原子扣减。注意这两步必须在同一个事务里锁的持有时间越短越好。如果用了 Redis 预扣余量也要考虑 Redis 和数据库的一致性问题。我个人的做法是数据库为准Redis 只做展示用的近似值。5.2 现象二预约接口偶发 500日志里出现 Deadlock 死锁现象是并发 100 左右时日志里频繁出现 Deadlock found when trying to get lock; try restarting transaction用户端表现为点提交按钮后转圈然后报错。原因是我在早期版本里预约接口先锁 t_appointment 再改 t_schedule取消接口先改 t_schedule 再查 t_appointment两个事务持锁顺序不一致就形成了循环等待。解决方法是把所有涉及号源和预约的事务统一成“先锁 t_schedule 行再写 t_appointment”也就是把 selectByIdForUpdate 放在整个方法的第一个数据库操作位置。同时对数据库的 innodb_lock_wait_timeout 做合理设置并且捕获死锁异常后做一次业务重试。死锁不是玄学它就是锁顺序的问题统一顺序后基本不会再出现。5.3 现象三过了 0 点当天未开始的预约全被标成 NO_SHOW现象是周一早上发现当天还没开始的所有预约全部变成了 NO_SHOW用户一觉醒来预约没了。原因是定时任务写的是每天 0 点执行代码里用了 LocalDate.now() 作为“当天”然后把“预约日期等于 now() 且状态还是 BOOKED”的记录批量标记成 NO_SHOW。任务本意是清理“今天没来”的人但 0 点已经跨天这个 now() 已经变成新的一天等于把新一天还没营业的预约全标记了。解决方法是批处理改成“预约日期等于昨天”并加时段结束时间判断或者干脆放在次日凌晨 2 点执行避开跨天边界。时间边界这个坑代码里看着只是 cron 差了两小时实际影响的是几百条预约的生效状态。5.4 现象四预约记录分页越翻越慢现象是后台列表页默认按创建时间倒序翻到第 50 页以后接口响应从 200ms 变成 3 秒。原因是 MySQL 的 LIMIT 1000, 20 是把前 1000 行全部查出来再丢弃OFFSET 越大扫描越多再叠加没有合适索引的排序字段慢查询直接拖垮整个库。解决方法是给预约表建联合索引 (appointment_date, created_time)分页方式改成游标分页也就是记住上一页最后一条记录的时间用 WHERE created_time #{lastTime} ORDER BY created_time DESC LIMIT 20。对导出全部数据的场景就不走列表接口单独用任务导出。游标分页在预约这种高频变化的表上比点击页码翻页更稳因为新插入的数据不会让后面页的数据整体偏移。5.5 现象五同一个身份证号在系统里出现两条主记录现象是同一个受检者先在小程序预约后来又去前台手工建档后台一看 t_member 表里有两个 ID体检报告数据分别挂在两条记录下。原因是建档时只做了“先查再插”没有唯一约束两个入口同时操作时查重都返回不存在就都插入成功了。解决方法是 t_member 的 id_card 字段建唯一索引插入用 INSERT INTO t_member (..., id_card) VALUES (...) ON DUPLICATE KEY UPDATE id LAST_INSERT_ID(id) 这种写法让并发重复插入变成幂等操作。对已经存在的重复数据写一个数据修复任务保留最早一条把后建的关联数据改绑到保留记录并在日志表记录变化不能直接物理删除。体检数据是医疗记录删了就再也回不来了后悔药是没有的。6. 给号源列表加一层本地缓存把热点查询从几十毫秒压到一毫秒内预约页的号源列表是整个系统里读请求最密集的接口。用户一打开小程序就要看未来 14 天哪些时段可约每次打开都要查 t_schedule 和 t_appointment这个查询本身不慢问题是它被高频重复执行。高峰期 10 分钟几千个请求数据库压力全耗在同一个查询上完全没必要。6.1 Caffeine 本地缓存与数据一致性我这边用的是 Caffeine 做本地缓存配置很短。定期失效时间选了 30 秒而不是 5 分钟原因是号源余量的实时性会影响用户决策。设置如下Configuration public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(schedule:list); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofSeconds(30))); return manager; } }Service 层查询时直接走缓存Cacheable(cacheNames schedule:list, key #date : #periodType) public ListScheduleVO listAvailable(LocalDate date, String periodType) { return scheduleMapper.listAvailable(date, periodType); }6.2 预约成功后主动失效缓存本地缓存最大的问题是多实例部署时各自缓存不一致。解决的办法很简单预约成功后在写操作里手动清除对应日期和时段的号源缓存而不是等 30 秒自然过期。Caffeine 的 expireAfterWrite 是兜底主动失效才能保证用户预约成功后刷新页面余量立刻变化。具体就是在 createAppointment 的 Service 实现里构造缓存 key 并调用 cacheManager 清空// 预约成功、事务提交之后清除对应日期和时段的号源缓存 cacheManager.getCache(schedule:list).evictIfPresent( schedule.getScheduleDate() : schedule.getPeriodType() );注意这段代码要放在事务提交之后执行否则事务还没提交就清缓存下一个请求读到的还是旧数据。最简单的方式是把 evict 放在事务方法外面或者用事务同步管理器注册 afterCommit 回调。这样设计之后号源列表接口的响应时间从数据库查询的几十毫秒降到一毫秒内数据库的 QPS 也降了一个数量级。这个技巧看着小但它是整套系统里性价比最高的一处优化。我自己后来做同类项目时已经把 Caffeine 缓存列成了标准配置凡是“多读少写、容忍秒级延迟”的列表都用这个套路。缓存不是银弹但把这个位置用好能让你省下很多扩数据库配置的预算。希望帮到你。本文还有配套的精品资源点击获取