SpringBoot会议管理系统:时间区间冲突检测与Redis分布式锁并发实践
2026/9/18 15:50:09 网站建设 项目流程

简介:面向计算机专业毕业设计场景的 SpringBoot 会议管理系统论文文档,适合正在选题、撰写论文或准备答辩材料的高校学生与开发者参考。文档围绕企业会议管理中记录分散、会议室协调困难与信息汇总效率低等问题,给出从需求分析、数据库设计到编码实现与测试上线的完整论述。正文涵盖员工管理、公告发布、会议室预订、会议资料上传下载、会议投票及意见收集等功能模块,并说明 MySQL 在用户表、会议表、公告表、会议室表等实体上的库表设计思路;开发部分以 SpringBoot 完成后端编码,前端采用 HTML、CSS 与 JavaScript,通过 API 接口实现前后端数据交互,测试环节涉及单元测试、集成测试与系统测试。资源包共 1 个 doc 文件,约 5.12MB,便于直接查阅章节结构、摘要与文字表述。目前已有 142 人学习下载,可为同类 Web 系统选题提供功能划分、库表设计和论文写作框架方面的参考。

1. 一份"会议管理系统论文.doc"背后,真正要写的是并发与冲突

很多人看到"基于 SpringBoot 的会议管理系统"这个题目,第一反应是又一个增删改查练手项目:会议室列表、会议新增、参会人勾选、导出 Excel,一周就能跑通。但真正把系统交给一个两百人的公司用,第一天就会出问题——两个人在同一秒选中了同一间会议室、同一个时间段,数据库里出现了两条互相重叠的预订记录。这时候你才会发现,这个题目的技术含金量根本不在 CRUD,而在"时间区间的重叠判定"和"并发抢占"这两件事上。

SpringBoot 在这里扮演的角色是"把工程复杂度压下去,把业务复杂度露出来":自动装配把数据源、Web 容器、事务管理器、JSON 序列化这些重复劳动收敛成几个 starter,你才有精力去处理预订冲突、权限分层、通知触达、参会人回执这些真正会写进论文"系统设计与实现"章节的东西。适合读这篇的人有三类:正在做这个毕设、需要一套能自圆其说的落地路径的学生;接手了公司内部会议室预约模块、需要补并发防护的后端;以及想借这个小场景把 SpringBoot 事务、锁、缓存、鉴权串一遍的开发者。

2. 会议管理系统的领域建模与 SpringBoot 工程骨架

领域模型定错了,后面所有代码都在还债。会议室预约类系统最容易踩的坑是把"会议"和"会议时间段"设计成一对一,结果一个跨半天、中间休息两次的会议就存不下;第二个坑是把参会人存成逗号分隔的字符串,导致"查某个人这周有哪些会"只能全表扫描。

2.1 四张核心表:会议室、会议、参会人、用户

先说结论,最小可用模型是四张表加一张关联表,其中"会议室"和"会议"之间通过"时间段 + 会议室"做冲突判定。下面是 MySQL 8 的建表语句,字段注释写清楚,论文里的数据库设计章节可以直接照着画 E-R 图。

-- 会议室表:物理资源,冲突判定的主体 CREATE TABLE `meeting_room` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_no` VARCHAR(32) NOT NULL COMMENT '房间编号,如 A-301', `name` VARCHAR(64) NOT NULL COMMENT '会议室名称', `capacity` INT NOT NULL DEFAULT 0 COMMENT '容纳人数', `location` VARCHAR(128) DEFAULT NULL COMMENT '所在楼层位置', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_room_no` (`room_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 会议表:一条记录代表一段被占用的时间区间 CREATE TABLE `meeting` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_id` BIGINT NOT NULL COMMENT '关联会议室', `title` VARCHAR(128) NOT NULL COMMENT '会议主题', `organizer_id` BIGINT NOT NULL COMMENT '发起人用户ID', `start_time` DATETIME NOT NULL COMMENT '开始时间', `end_time` DATETIME NOT NULL COMMENT '结束时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1已通过 2已驳回 3已取消 4已结束', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_time` (`room_id`, `start_time`, `end_time`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 参会人表:会议与用户的多对多 CREATE TABLE `meeting_participant` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `meeting_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `reply_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未回执 1参加 2请假', PRIMARY KEY (`id`), UNIQUE KEY `uk_meeting_user` (`meeting_id`, `user_id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三个设计点值得单独说明。idx_room_time这个联合索引的字段顺序不能改,因为冲突查询永远以room_id作为等值条件、以start_time作为范围条件,把room_id放最左边才能让 MySQL 走到索引下推。uk_meeting_user保证同一个人不会被重复拉进同一个会议,这是幂等性的数据库级保障,比在 Service 里 if 判断可靠得多。status用 TINYINT 而不是字符串,是为了让"已取消的会议不参与冲突判定"这类条件能直接进索引。

2.2 用 Spring Initializr 拉起工程:依赖别乱勾

在 start.spring.io 或 IDEA 的 Spring Initializr 向导里建项目,第一件要决定的事是 JDK 版本,而不是先勾依赖。Spring Boot 3.x 已经把最低要求提到 JDK 17,网上那批"IDEA 不能用 JDK 1.8 创建 SpringBoot 项目"的帖子,根源就在这里:向导里选 3.x 版本、本地 JDK 却是 8,校验直接不通过。要么把 JDK 升到 17,要么把 Spring Boot 版本降到 2.7.x 这条最后的 8 兼容线,两者必须对齐,中间没有折中方案。

依赖按下面的清单勾,多一个少一个都会在后期补得很痛苦:

依赖用途不勾的后果
Spring WebREST 接口、内嵌容器起不来 Web 环境
Spring Data Redis登录态缓存、预订锁并发预订只能靠数据库扛
MyBatis FrameworkSQL 可控的持久层JPA 写复杂冲突查询很别扭
MySQL Driver驱动启动即报找不到驱动
Lombok省 getter/setter实体类膨胀几百行
Validation参数校验非法时间区间直接进库
Spring Boot DevTools改代码热重启调试效率低一截

MyBatis 和 JPA 之争在这个场景里有明确答案:冲突判定那段 SQL 需要精准控制索引和条件顺序,MyBatis 的 XML 或注解方式更好把握;如果你更想要开发速度,MyBatis-Plus 是折中选项,它的LambdaQueryWrapper拼接出来的 SQL 也还能看。

2.3 包结构与分层职责

包结构不用追求花样,按"接口层—业务层—数据层"三段式切,再补两个横切包:

com.example.meeting ├── MeetingApplication.java ├── common/ # 统一响应体、全局异常、常量 ├── config/ # Redis、Security、Cors、MyBatis 配置 ├── controller/ # 只做参数接收和响应包装,不写业务 ├── service/ │ └── impl/ # 事务边界在这一层,锁也在这一层 ├── mapper/ # 只放 SQL 接口 ├── entity/ # 与表一一对应 ├── dto/ # 入参出参,与 entity 隔离 └── job/ # 定时任务:会议状态流转、过期提醒

controller里出现@Transactional是这套结构里最常见的越界。事务应该开在 Service,因为一次"校验冲突 + 写会议 + 写参会人"必须是一个原子操作,放在 Controller 里会导致事务范围包住了参数校验和响应序列化,锁持有时间被无意义拉长。

2.4 application.yml 里必须先定下来的几个参数

配置文件不是把默认值抄一遍,而是把"出问题时会浪费你半天时间"的那几项显式写出来。

spring: datasource: url: jdbc:mysql://127.0.0.1:3306/meeting_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false username: meeting password: ${DB_PASSWORD} # 不写死,环境变量注入 hikari: maximum-pool-size: 20 # 默认10,按 4 倍 CPU 核数上限估 minimum-idle: 5 connection-timeout: 3000 # 3秒拿不到连接就快速失败 max-lifetime: 1740000 # 比 MySQL wait_timeout 略小,避免用死连接 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai redis: host: 127.0.0.1 port: 6379 timeout: 2000ms mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

serverTimezonespring.jackson.time-zone必须同时设成Asia/Shanghai。只设一个的典型症状是:前端传"14:00"进来,数据库存成"06:00",或者反过来,排查半天以为是前端时区问题。connection-timeout从默认的 30 秒改成 3 秒,是因为预订高峰期连接池打满时,让请求快速失败给用户一个"系统繁忙"提示,比让浏览器转圈 30 秒再超时体验好得多。另外max-lifetime要略小于 MySQL 的wait_timeout(默认 28800 秒),避免连接池里握着已被服务端单方面关闭的连接。

3. 会议室预订冲突检测:时间区间算法与并发控制

这部分是整个系统的技术核心,也是论文里最值得展开写的算法章节。核心问题可以一句话描述:给定一间会议室和一个待预订区间[start, end),判断它是否与已有区间重叠。

3.1 时间重叠判定:一行 SQL 说清楚

判断两个区间[s1, e1)[s2, e2)是否重叠,数学上等价于s1 < e2 AND e1 > s2。注意这里是严格小于和严格大于,因为会议时间用左闭右开区间表示,10:00-11:0011:00-12:00不算冲突,这直接影响用户能不能订连续的两场会。

SELECT COUNT(1) FROM meeting WHERE room_id = #{roomId} AND status IN (0, 1) -- 待审和已通过都要占坑 AND start_time < #{endTime} AND end_time > #{startTime};

返回值大于 0 就说明冲突,直接抛业务异常。这里有两个容易写错的点。第一,status必须包含"待审",如果只算"已通过",那么同一个会议室可以同时收到十份待审申请,审批时才发现互相打架。第二,条件里不要写BETWEENBETWEEN是闭区间语义,会把边界相接的两场会判成冲突。

3.2 索引与执行计划:为什么冲突查询会越来越慢

会议表跑到十万行量级时,如果索引不对,这条查询会从毫秒级掉到几百毫秒。用EXPLAIN验证索引命中情况:

mysql -h127.0.0.1 -umeeting -p meeting_db \ -e "EXPLAIN SELECT COUNT(1) FROM meeting WHERE room_id=3 AND status IN (0,1) AND start_time<'2025-06-01 12:00:00' AND end_time>'2025-06-01 10:00:00'\G"

期望看到的结果是type=rangekey=idx_room_timerows在几十以内。如果typeALL,说明索引没走上,通常有两个原因:一是联合索引字段顺序写成了(start_time, room_id, ...),范围条件在最左边会让后面的等值条件失效;二是status用了字符串类型,隐式类型转换导致索引失效。

索引方案命中情况适用场景
(room_id, start_time, end_time, status)最优,range 扫描会议室数量多、单房间会议密集
(room_id, status, start_time)良好,等值前置待审记录占比很低时
(start_time, room_id)差,全表范围扫描不推荐,只适合按时间全局查询

3.3 用 Redis 分布式锁兜住并发预订

SQL 层面的冲突检测解决的是"已经写进库的数据",解决不了"两个请求同时查库、同时发现没冲突、同时插入"。压测一下就知道:20 个线程同时订同一间会议室的同一时段,不加锁能成功十几条。加锁的最小实现是基于 Redis 的SET NX PX

@Service public class MeetingBookingService { private static final String LOCK_KEY = "meeting:lock:room:%d:%s"; private final StringRedisTemplate redisTemplate; private final MeetingMapper meetingMapper; public Long book(Long roomId, LocalDateTime start, LocalDateTime end, Long userId) { // 锁粒度:房间 + 起始时间,避免不同时段的预订互相阻塞 String key = String.format(LOCK_KEY, roomId, start.toLocalDate()); String token = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(key, token, Duration.ofSeconds(10)); // 10秒自动过期,防死锁 if (!Boolean.TRUE.equals(locked)) { throw new BizException("该时段正在被预订,请稍后重试"); } try { int conflict = meetingMapper.countConflict(roomId, start, end); if (conflict > 0) { throw new BizException("该会议室在所选时段已被占用"); } return insertMeeting(roomId, start, end, userId); } finally { // 用 Lua 保证"判断 token 再删除"的原子性,防止误删别人的锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), token); } } }

锁的粒度按"房间 + 日期"而不是"房间"来切,是权衡的结果:按房间锁会让同一天不同时段的预订全部串行化,QPS 掉得很难看;按"房间 + 精确到分钟的时间点"锁,又会出现10:00-11:0010:30-11:30拿到两把不同的锁、照样冲突的问题。按天锁粒度换取的是正确性,配合 10 秒的过期时间,单房间一天的并发预订被串行化,对中小企业场景完全够用。

提示:setIfAbsent的过期时间必须是明确的数值,不能依赖"先 set 再 expire"两步走,那两步之间宕机就是永久死锁。

3.4 数据库层的最后一道防线:唯一约束与槽位法

分布式锁依赖 Redis 可用,一旦 Redis 抖动或者有人绕过 service 直连数据库,冲突仍然会发生。更彻底的方案是把时间粒度化:以 15 分钟为一个槽位,把每场会议拆成若干条槽位记录,在(room_id, slot_time)上建唯一索引。

CREATE TABLE `room_slot` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_id` BIGINT NOT NULL, `slot_time` DATETIME NOT NULL COMMENT '槽位起点,15分钟对齐', `meeting_id` BIGINT NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_room_slot` (`room_id`, `slot_time`) ) ENGINE=InnoDB;

插入时如果任何一条槽位违反唯一约束,整批插入在事务里回滚,数据库帮你把并发问题解决了。代价是不支持任意分钟粒度(只能订 15 分钟整数倍)、槽位表数据量是会议表的数倍。槽位法适合规则严格的场景,比如学校教室预约;企业会议室允许"10:05 开始"这种随意时间的话,还是前面那套"Redis 锁 + 冲突 SQL"更合适。

4. 权限、参会人与通知:SpringBoot 整合 Security、Redis 与消息队列

会议管理系统天然有三类角色,权限模型设计得好,前端菜单和后端接口能共用一套判断逻辑。

4.1 角色模型与接口级鉴权

角色可见范围关键权限
普通员工自己发起的会议 + 被邀请的会议发起预订、回执、取消自己的会议
会议室管理员本部门全部会议审批、驳回、强制取消、查看冲突统计
系统管理员全部会议室维护、用户管理、数据导出

权限不落在角色名上,而是落在权限点上,角色只是权限点的集合。Spring Security 的开方法上直接写权限点:

@RestController @RequestMapping("/api/meeting") public class MeetingController { @PostMapping("/book") @PreAuthorize("hasAuthority('meeting:book')") // 普通员工即可 public R<Long> book(@RequestBody @Valid BookingDTO dto) { return R.ok(bookingService.book(dto)); } @PostMapping("/{id}/approve") @PreAuthorize("hasAuthority('meeting:approve')") // 仅管理员 public R<Void> approve(@PathVariable Long id, @RequestParam boolean pass, @RequestParam(required = false) String reason) { return R.ok(approvalService.approve(id, pass, reason)); } }

@Valid加在BookingDTO上,配合@Future@NotNull等注解,能在进入业务逻辑之前就把"结束时间早于开始时间"这类明显错误挡掉,省掉一次数据库往返。审批流程如果要做成多级(部门主管 → 行政 → 会议室管理员),简单的状态机够用;一旦要把审批节点、会签、撤回全部配置化,考虑引入 Flowable 这类流程引擎,但那是另一个量级的复杂度,毕设阶段不建议展开。

4.2 JWT + Redis 的登录态设计

纯 JWT 的痛点是没法主动踢人下线,会议系统里"账号在别处登录"是真实需求,所以常见做法是 JWT 只承载用户标识,真正的会话状态放 Redis:

public String login(String username, String rawPassword) { SysUser user = userMapper.findByUsername(username); if (user == null || !passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BizException("用户名或密码错误"); } String token = jwtUtil.generate(user.getId(), user.getUsername()); // 会话信息写 Redis,TTL 与 JWT 有效期一致 String redisKey = "login:token:" + user.getId(); redisTemplate.opsForValue().set(redisKey, token, Duration.ofHours(2)); return token; } public boolean validate(String token) { Long userId = jwtUtil.getUserId(token); String cached = redisTemplate.opsForValue().get("login:token:" + userId); return token.equals(cached); // Redis 里被删掉即视为已下线 }

这样"强制下线"只需要DELETE login:token:{userId},JWT 本身不用维护黑名单。校验器要放进 Spring Security 的过滤器链里,注意shouldNotFilter要放行登录接口和静态资源,否则会出现"登录接口也要 token"的死循环。

4.3 会议通知:本地事件与消息队列的取舍

会议创建、审批通过、会议取消、会前一小时提醒,这四类动作都要发通知。通知渠道可能有站内信、邮件、企业 IM。如果直接在book()方法里同步发邮件,一次 SMTP 连接可能耗时几百毫秒,把整个预订接口拖慢;邮件服务挂了还会导致预订事务回滚,这是典型的可用性倒挂。

单机部署用 Spring 的本地事件就够:

// 发布方:在事务提交后再触发,避免通知发了但事务回滚 @Service public class BookingService { private final ApplicationEventPublisher publisher; @Transactional public Long book(BookingDTO dto) { Long id = doBook(dto); publisher.publishEvent(new MeetingCreatedEvent(id, dto)); return id; } } // 监听方:默认同步执行,加 @Async 后走线程池 @Component public class MeetingNoticeListener { @Async("noticeExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onCreated(MeetingCreatedEvent event) { noticeService.sendToParticipants(event.getMeetingId(), "新会议邀请"); } }

AFTER_COMMIT这个阶段很关键,如果监听器在事务提交前执行,一旦后续回滚,参会人会收到一封"会议不存在"的邀请。@Async需要的线程池要自己定义并设置成CallerRunsPolicy的拒绝策略,否则线程池满时任务会被静默丢弃,通知就丢了。

如果系统要横向扩展成多实例,本地事件只在当前 JVM 内传播,这时候需要引入消息中间件。SpringBoot 整合 RocketMQ 的场景下,注意 topic 的规划不要一个业务一个 topic,按"通知"这个域建一个 topic、用 tag 区分"会议创建""会议取消",消费者按 tag 过滤订阅,可以避免 topic 数量爆炸;整合 ActiveMQ 的话,会议通知这种场景用 queue 模式(点对点)比 topic 模式更稳,一个通知只被一个消费者处理一次。选哪个不是关键,关键是别在业务方法里同步做远程调用。

4.4 参会人回执状态机

回执状态只有三个:未回执、参加、请假。看似简单,但状态流转必须约束死:已请假的可以改成参加,已参加的可以改成请假,取消的会议不允许再回执。与其在 Service 里写一堆 if,不如定义一个允许的转移集合:

private static final Map<Integer, Set<Integer>> ALLOWED = Map.of( 0, Set.of(1, 2), // 未回执 -> 参加 / 请假 1, Set.of(2), // 参加 -> 请假 2, Set.of(1) // 请假 -> 参加 ); public void reply(Long meetingId, Long userId, int target) { MeetingParticipant p = participantMapper.find(meetingId, userId); if (!ALLOWED.getOrDefault(p.getReplyStatus(), Set.of()).contains(target)) { throw new BizException("当前状态不允许此操作"); } participantMapper.updateReplyStatus(meetingId, userId, target); }

状态机用表驱动而不是 if-else 链,好处是以后加"待定"状态时只改一行配置,不用去翻散落各处的判断。

5. 前后端分离联调:接口契约、分页检索与配置调优

前端用 Vue、后端用 SpringBoot 的组合已经成了默认选项,联调阶段最耗时间的往往不是业务逻辑,而是响应格式对不齐、日期格式对不齐、跨域报错这三件事。

5.1 统一响应体与全局异常

所有接口统一返回{code, msg, data}三段式,前端就能写一个拦截器统一处理错误提示,不用每个接口单独判断。

// 全局异常处理:把业务异常翻译成统一格式 @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public ResponseEntity<R<Void>> handleBiz(BizException e) { return ResponseEntity.ok(R.fail(4001, e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<R<Void>> handleValid(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + " " + f.getDefaultMessage()) .collect(Collectors.joining("; ")); return ResponseEntity.ok(R.fail(4002, msg)); } }

业务异常统一返回 HTTP 200、用code区分,是为了让前端拦截器逻辑简单——只需要看code字段。如果业务错误也返回 4xx/5xx,浏览器控制台会一片红,真正的网络错误反而被淹没。

5.2 跨域、日期序列化与时区

前后端分离部署在不同端口时,跨域配置要在后端统一处理,而不是让前端每个请求都带代理:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("http://localhost:*") // 用 patterns 才能配合 allowCredentials .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); // 预检结果缓存 1 小时,减少 OPTIONS 请求 } }

注意allowedOrigins("*")allowCredentials(true)不能同时用,浏览器会直接拒绝,必须换成allowedOriginPatterns。日期统一用LocalDateTime+ Jackson 的JavaTimeModule@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")只加在个别字段上,全局配置写在application.yml里更省事。前后端约定时间一律传输"不带时区的字符串",由后端在进入业务逻辑前统一转成Asia/Shanghai,能规避掉绝大多数"时间差 8 小时"的沟通成本。

5.3 多条件分页检索

会议列表的检索条件通常有四五个:会议室、时间段、状态、发起人、关键词。用 MyBatis 的动态 SQL 拼,注意WHEREAND的处理:

<select id="pageQuery" resultType="com.example.meeting.dto.MeetingVO"> SELECT m.id, m.title, r.room_no, m.start_time, m.end_time, m.status FROM meeting m JOIN meeting_room r ON r.id = m.room_id <where> <if test="roomId != null">AND m.room_id = #{roomId}</if> <if test="status != null">AND m.status = #{status}</if> <if test="organizerId != null">AND m.organizer_id = #{organizerId}</if> <if test="start != null">AND m.end_time &gt; #{start}</if> <if test="end != null">AND m.start_time &lt; #{end}</if> <if test="keyword != null and keyword != ''"> AND m.title LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY m.start_time DESC LIMIT #{offset}, #{size} </select>

时间段筛选同样用重叠判定而不是BETWEEN,语义才和冲突检测保持一致。LIMIToffset在深分页时(比如第 1000 页)会退化成全表扫描,会议数据量不大时可以不管,真要优化就换成基于start_time的游标分页。关键词模糊查询走LIKE '%x%'用不上索引,数据量上来了要么上全文索引,要么把关键词检索交给独立的搜索组件。

5.4 HikariCP 与日志的调参底线

压测或者线上出问题时,能看的只有日志和连接池指标。这两处的最低配置:

配置项建议值说明
hikari.maximum-pool-size20经验上限是CPU核数 * 2 + 磁盘数,再大只会增加上下文切换
hikari.connection-timeout3000ms快速失败,避免请求堆积
hikari.leak-detection-threshold20000ms连接泄漏检测,超时打印堆栈
logging.level.com.example.meeting.mapperdebug只在排查期开,能看到真实执行的 SQL 和参数
spring.mvc.log-resolved-exceptiontrue记录被异常处理器吞掉的异常

logging.level打到 mapper 包上时,日志里会打印完整 SQL 和参数绑定结果,是排查"查询结果不对"最快的手段,但生产环境一定要关回 info,否则日志量会大到磁盘报警。

6. 最后一章:用并发压测把会议管理系统的预订漏洞逼出来

功能测试跑通不代表系统没问题。会议室预订这类接口,单人点一遍永远是对的,只有并发才会暴露漏洞。压测不需要复杂的工具,ab或者 JMeter 就够,关键是把请求构造得"足够像真实冲突"。

# 20 个并发、总共 200 次请求,全部打向同一个会议室的同一时段 ab -n 200 -c 20 -p booking.json -T application/json \ -H "Authorization: Bearer ${TOKEN}" \ http://127.0.0.1:8080/api/meeting/book

booking.json里所有请求的roomIdstartTimeendTime都写死成同一个值。然后去数据库里数一下:

SELECT COUNT(*) FROM meeting WHERE room_id = 1 AND start_time = '2025-06-01 10:00:00' AND status IN (0, 1);

这个数字必须是 1。如果是 2 以上,说明锁没生效,按这个顺序排查:Redis 是否真的连上了(看启动日志里的LettuceConnectionFactory)、setIfAbsent的返回值是否被Boolean.TRUE.equals()正确判断(用Boolean自动拆箱遇到 null 会 NPE)、锁的 key 拼接里有没有漏掉roomId、事务的隔离级别是不是被改成了READ_UNCOMMITTED

压测跑完之后最该看的是日志里的时间戳分布。单机场景下,用 AOP 给每个请求打上 traceId 并记录耗时,能一眼看出瓶颈在锁等待还是数据库:

@Aspect @Component public class TraceAspect { @Around("execution(* com.example.meeting.service..*(..))") public Object trace(ProceedingJoinPoint pjp) throws Throwable { String traceId = MDC.get("traceId"); // 由过滤器提前写入 long start = System.currentTimeMillis(); try { return pjp.proceed(); } finally { long cost = System.currentTimeMillis() - start; if (cost > 500) { // 只记录慢调用,避免刷屏 log.warn("[{}] {}.{} cost={}ms", traceId, pjp.getSignature().getDeclaringType().getSimpleName(), pjp.getSignature().getName(), cost); } } } }

压测结果通常会出现三种典型形态,对应三种不同的修法。第一种,200 次请求全部返回"该时段正在被预订",说明锁生效但粒度太粗,把锁 key 从"房间 + 日期"细化到"房间 + 日期 + 上午/下午",并发度立刻改善。第二种,平均耗时几百毫秒但 P99 冲到两三秒,多半是连接池被打满,把maximum-pool-size和 MySQL 的max_connections一起往上调,同时确认没有在事务里做远程调用。第三种,日志里出现大量Deadlock found when trying to get lock,说明"写会议 + 写参会人 + 写槽位"三张表的加锁顺序在并发下不一致,统一成按表名固定顺序写入即可。

压测数据本身也该写进论文的"系统测试"章节:并发数、平均响应时间、成功率、冲突拦截次数,比截图几张功能页面有说服力得多。最后留一个容易忽略的检查项——把 Redis 停掉再压一次。如果系统直接抛 500 而不是降级成"预订功能暂时不可用,请稍后重试",说明锁的异常处理没有兜住,这类失败路径的健壮性,恰恰是评审老师最容易追问的地方。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询