ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于微信小程序与SSM框架的会议发布预约系统设计与并发控制实践

基于微信小程序与SSM框架的会议发布预约系统设计与并发控制实践 简介这是一份面向毕业设计或课程设计的完整项目源码基于微信小程序和SSM框架实现会议发布与预约系统涵盖小程序用户端与后台管理端适合Java学习者、小程序开发爱好者以及需要快速搭建会议管理系统的学生参考。压缩包共1139个文件大小约14.9MB主要包含Java后端源码95个java、Vue后台管理页面121个vue、微信小程序前端wxml、wxss、js等、SQL脚本及构建运行脚本覆盖前后端与部署配置。系统实现了用户注册登录、会议信息浏览、热门推荐、预约申请、预约审批、提醒通知、预约统计等完整流程前后端通过RESTful API交互使用MyBatis持久化数据功能模块划分清晰。当前已有89人学习下载包内附有数据库初始化文件、启动脚本和静态资源便于本地运行、二次开发或直接作为毕业设计展示整体结构对二次开发友好。1. 会议发布与预约系统ssm框架先想清楚这套系统在解决什么“基于微信小程序的会议发布与预约系统的设计与开发”这个题目往小了说是给单位内部做一套会议室管理工具往大了说是把“会议资源”当作可发布、可查询、可抢占、可释放的数据来管。后端选 SSMSpring SpringMVC MyBatis前端用微信小程序是这类课题里最经典、也最好落地的组合用户不用装 App组织者不用进 OA 系统扫一眼小程序就能看到今天有哪些会议、还剩几个名额、自己约没约上。这套系统适合两类人一是正在做 Java Web 课程设计或毕业设计的同学需要一个能讲清楚架构、能跑通演示的业务闭环二是企业内部需要一套轻量会议工具但又不想为一个小功能引入整套微服务中台。它真正的难点不在 CRUD而在三个地方并发预约时不超卖、会议状态和时间不乱、小程序弱网环境下不重复提交。下文会把这三条主线全部拆开从后端骨架一直写到前端避坑。2. SSM框架先立住三个框架的分工与最小可运行骨架2.1 为什么这个项目要选 SSM而不是一上来就 Spring Boot很多人问过我这个选择。SSM 确实是“老技术”但到现在依然是教学体系里最常见的组合原因很直接它把框架之间的边界暴露得很清楚适合理解 Java Web 的传统分工。Spring 管对象创建和事务SpringMVC 管 HTTP 请求路由和 JSON 互转MyBatis 管 SQL 与 Java 对象的映射。每一层都能在配置文件里明确看到出了问题也知道去哪个文件翻。如果我完全按生产环境从零选型会优先用 Spring Boot MyBatis少写一半 XML但既然标题锁定 SSM就把它作为主线讲透——它的分层思想迁到 Spring Boot 后依然成立不算白学。实际开发时我用 Maven 管理依赖整个后端分包大致是controller 放接口入口service 放业务逻辑mapper 放 MyBatis 的数据库访问entity 放与表对应的实体类。MyBatis 的 Mapper 接口只定义方法SQL 写在 resources/mapper 下的 XML 文件中。这样做的好处是后面换 Spring Boot 时 controller/service/entity 三层代码原样搬走只需要替换掉配置和依赖。2.2 用 Maven 搭出最小可运行骨架pom、web.xml、spring-mybatis 配置先给 pom.xml 里最核心的依赖做减法只留六组其余按需补。注意我这里不写具体版本号而是建议你用一个统一的 Spring 版本管理避免 Spring 5 和 Spring 4 的包混在一起导致启动时一堆 NoSuchMethodError。properties spring.version5.3.x/spring.version mybatis.version3.5.x/mybatis.version mysql.version8.0.x/mysql.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.x/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql.version}/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.x/version /dependency /dependenciesspring-webmvc 是 SpringMVC 的入口spring-jdbc 提供事务管理器mybatis-spring 负责把 MyBatis 的 SqlSessionFactory 交给 Spring 容器管理。这里的版本号我给的是主版本区间具体到某一版要看你的 JDK 版本JDK 8 用 Spring 5.3 没问题JDK 17 也可以但要注意把 javax.servlet 换成 jakarta.servlet 相关坐标否则 DispatcherServlet 起不来。然后是 web.xml这是 SSM 项目的入口配置。它的作用是把所有 HTTP 请求交给 DispatcherServlet 转发servlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mappingload-on-startup 填 1表示 Tomcat 启动时就初始化这个 Servlet而不是等第一个请求进来才初始化。url-pattern 写成/意思是所有请求都进 SpringMVC静态资源要单独配 mvc:resources 放行不然小程序端拉不到图片或 HTML 页面。接下来是最容易出问题的 spring-mybatis 配置。SSM 整合 MyBatis 时核心是 SqlSessionFactoryBean 和 MapperScannerConfigurerbean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/meeting_sys?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghaiamp;useSSLfalse/ property nameusername valueroot/ property namepassword value你的密码/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.meeting.mapper/ /beanJDBC URL 里的 serverTimezoneAsia/Shanghai 必须带否则 MySQL 8 连接时会报时区错误。useSSLfalse 是本地开发用的生产环境如果数据库没有配 SSL 证书也建议保持 false。MapperScannerConfigurer 的作用是扫描 com.meeting.mapper 包把里面的接口自动注册成 Spring 管理的 Bean这样 service 里直接用 Resource 注入 Mapper 接口就行不需要手写实现类。2.3 第一个接口跑通Controller 到 Service 到 Mapper 的完整链路骨架搭起来后先跑通一个最简单的查询接口验证配置是否正确。我在 MeetingController 里写一个分页查询入口前端小程序后续所有请求都照这个模式扩展RestController RequestMapping(/api/meeting) public class MeetingController { Resource private MeetingService meetingService; GetMapping(/list) public Result list(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { return Result.success(meetingService.page(page, size)); } }这里有几个细节值得说。RestController 是 Controller 和 ResponseBody 的合并方法返回值直接序列化成 JSON不用再在方法上重复加 ResponseBody。Result 是我自定义的统一返回结构里面包含 code、message、data 三个字段小程序端就靠 code 判断业务成功还是失败。分页参数用 defaultValue 指定默认值前端不传 page 和 size 时不会报错。Service 层要加 Service 注解并处理事务。分页查询是只读操作不需要开启事务但后面的预约、取消、发布接口我会在每个写方法上加 Transactional(rollbackFor Exception.class)保证多条 SQL 要么全部成功、要么全部回滚。这一点放在下一章结合预约场景具体讲因为它是整个系统最容易翻车的地方。Service public class MeetingService { Resource private MeetingMapper meetingMapper; public PageResult page(int page, int size) { int offset (page - 1) * size; ListMeeting list meetingMapper.page(offset, size); long total meetingMapper.count(); return new PageResult(total, list); } }select idpage resultTypecom.meeting.entity.Meeting SELECT id, title, location, start_time, end_time, capacity, remain_count, status FROM meeting WHERE status IN (0, 1) ORDER BY start_time ASC LIMIT #{offset}, #{size} /select分页的 offset 在 Service 里算好SQL 里直接用 LIMIT 接收这是最简单、也最好调试的分页方式。不要在小程序端传页码给 SQL 做 offset因为前端传过来的 page 是 1 开始的数据库是从 0 开始的很容易查错数据。这个接口跑通后说明从 HTTP 到 SpringMVC 到 Spring 再到 MyBatis 这条链路是通的后面所有业务功能都在这个链路上叠加。3. 会议与预约的数据模型两张核心表和四个关键接口3.1 meeting 与 reservation 表字段设计、状态机和唯一索引会议发布与预约的业务核心是两张表会议表和预约表。会议表存“场次信息”预约表存“谁约了哪场”。设计表时我优先考虑查询效率和三张状态流而不是追求字段多。CREATE TABLE meeting ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 会议标题, location VARCHAR(100) NOT NULL COMMENT 会议地点, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, capacity INT NOT NULL DEFAULT 0 COMMENT 可预约总名额, remain_count INT NOT NULL DEFAULT 0 COMMENT 剩余名额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束 3已取消, creator_id BIGINT NOT NULL COMMENT 发布人ID, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_start_time (start_time), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meeting_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_meeting_user (meeting_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status 字段单独建索引是因为会议列表页最常见操作就是按状态过滤。预留 remain_count 字段虽然可以用 capacity 减去预约数实时算但实时统计在列表页会变成一个子查询数据量上来后很伤性能。对于中小型系统直接冗余一个剩余数字段预约成功时减一、取消时加一代价最小。后面要讲的超卖问题重点也是保证这个字段不被并发请求扣成负数。reservation 表上的唯一索引 uk_meeting_user 是整个预约防重的关键。没有它同一用户在同一场会议下可以插入多条记录有了它数据库层面就拦住了重复预约。这个索引在下面的预约接口和最后的进阶验证里都会用到。3.2 发布会议接口时间校验与状态机推进发布会议是管理员的动作。接口接收标题、地点、开始时间、结束时间、总名额后端要做两个校验开始时间必须晚于当前时间结束时间必须晚于开始时间。Transactional(rollbackFor Exception.class) public Long publish(Meeting meeting) { LocalDateTime now LocalDateTime.now(); if (meeting.getStartTime().isBefore(now)) { throw new BizException(会议开始时间不能早于当前时间); } if (!meeting.getEndTime().isAfter(meeting.getStartTime())) { throw new BizException(结束时间必须晚于开始时间); } meeting.setRemainCount(meeting.getCapacity()); meeting.setStatus(0); meetingMapper.insert(meeting); return meeting.getId(); }为什么发布时要校验时间因为会议状态机完全由时间驱动status0 表示未开始到了开始时间自动切换成进行中过了结束时间切换成已结束。这个切换可以靠定时任务扫描也可以约定前端根据当前时间判断显示什么状态。如果发布时允许开始时间在过去状态机初始状态就是错的后续所有列表和预约逻辑都会乱套。这里有个链路上的细节前端小程序传过来的时间是字符串比如 2024-06-01 09:30:00SpringMVC 默认不会自动把它转成 LocalDateTime。常见做法是在 controller 里用 DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) 注解参数或者在全局配置一个 Jackson 的日期反序列化器。我一般直接在实体类的 startTime 字段上加 JsonFormat(pattern yyyy-MM-dd HH:mm:ss)这样从 JSON 反序列化和向 JSON 序列化时都能统一格式避免前端拿到带 T 的 ISO 字符串。3.3 预约会议接口原子扣减 事务回滚根治超卖预约是整套系统最核心的接口。需求是用户选择一场未开始的会议点击预约如果还有剩余名额就成功同时生成一条预约记录。最容易想到的写法是“先查余量再判断再更新”但并发场景下这是错的// 反面例子不要这样写 Meeting meeting meetingMapper.selectById(id); if (meeting.getRemainCount() 0) { meetingMapper.decreaseRemain(id); reservationMapper.insert(meetingId, userId); }两个用户同时读到 remain_count1都进入 if 分支两个人都扣减成功余额变成 -1。这就是典型的超卖。正解是把“判断余量”和“扣减”合并到一条 UPDATE 语句里让数据库在行锁层面保证原子性Transactional(rollbackFor Exception.class) public void reserve(Long meetingId, Long userId) { int rows meetingMapper.deductRemain(meetingId); if (rows 0) { throw new BizException(名额不足或会议已不可预约); } try { reservationMapper.insert(meetingId, userId); } catch (DuplicateKeyException e) { throw new BizException(你已经预约过这场会议不要重复提交); } }对应的 MyBatis SQL 这样写update iddeductRemain UPDATE meeting SET remain_count remain_count - 1 WHERE id #{meetingId} AND remain_count 0 AND status 0 /update这段 SQL 的巧妙之处在于把 remain_count 0 写进 WHERE。MySQL 执行 UPDATE 时会锁住命中的行同一时刻只有一个事务能修改这条记录。如果剩余名额为 0影响行数是 0Java 里 rows 0 就抛异常不会继续插入预约记录。重复预约的拦截也值得说清楚。reservation 表有唯一索引 uk_meeting_user当同一个用户再次插入时会触发 DuplicateKeyException。在 Transactional 的方法里这个异常发生后Spring 会标记整个事务为 rollback-only——也就是说前面那次 deductRemain 的扣减也会跟着回滚不会出现“扣了名额但预约记录没插进去”的半成品状态。这里我不需要手动补回余量事务机制帮我做了。3.4 取消预约与列表查询事务边界和排序规则取消预约是预约的反向操作。用户取消时先将 reservation 记录置为已取消再把 meeting 表的 remain_count 加一Transactional(rollbackFor Exception.class) public void cancel(Long meetingId, Long userId) { int rows reservationMapper.cancel(meetingId, userId); if (rows 0) { throw new BizException(预约记录不存在或已取消); } meetingMapper.increaseRemain(meetingId); }这里要特别说明我用的是“更新状态”而不是“删除记录”。之所以保留预约记录是为了让组织者能统计“谁约过又取消了”这是会议管理里的常见需求。cancel 方法里先更新 reservation 再增加余量如果 increaseRemain 失败整个事务回滚预约记录不会被置成已取消。这样即使后一条 SQL 报错数据也不会处于“状态取消但名额没还回来”的状态。列表查询同样要围绕状态和时间组织。三条规则我比较常用默认只看未开始和进行中的会议已经结束的默认不出现在主列表按开始时间升序排列越早开始的越靠前每页固定返回 total方便小程序端做上拉加载。select idpage resultTypecom.meeting.entity.Meeting SELECT id, title, location, start_time, end_time, capacity, remain_count, status FROM meeting WHERE status IN (0, 1) ORDER BY start_time ASC LIMIT #{offset}, #{size} /select select idcount resultTypelong SELECT COUNT(*) FROM meeting WHERE status IN (0, 1) /select分页查询和 count 查询分开写看起来多了一段 SQL但比 MyBatis 插件自动生成 count 更可控。count 里没有 ORDER BYMySQL 执行时会省掉排序开销数据量大了以后差异很明显。至于“进行中”的会议要不要允许预约产品上通常不允许因为会议已经开始了预约没有意义。所以我上面的 SQL 里预约条件是 status 0而列表展示条件放宽到 status IN (0, 1)。这两个状态判断不要混在一起否则会出现“列表能看到进行中的会议但预约时报名额不足”的困惑。4. 小程序端对接登录、请求封装、页面状态控制4.1 登录态怎么打通wx.login 换 code后端换 openid小程序端不能直接拿到用户身份标准流程是小程序调用 wx.login 获取一个临时 code把 code 传给后端后端用 code 加上小程序的 appid 和 secret向微信官方接口换 openid。换到 openid 后后端把它当作用户唯一标识生成一个自己的 token 返回给小程序之后所有请求都带这个 token。小程序端登录代码wx.login({ success: async (res) { if (res.code) { const data await request(/api/auth/login, POST, { code: res.code }) wx.setStorageSync(token, data.token) wx.setStorageSync(userId, data.userId) } } })后端 authService 里调用微信接口注意 GET 请求的四个参数一个都不能少String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code code grant_typeauthorization_code;secret 要从服务端拿不能写在小程序代码里否则等于把密钥公开出去了。我一般把 appid 和 secret 放进 properties 配置文件部署时用环境变量覆盖。拿到微信返回的 openid 后先查用户表有没有这个 openid没有就自动注册一条新用户记录再生成 token 存 Redis 或数据库。token 的有效期建议设成一个长效值比如 7 天因为会议预约场景不需要特别高的安全性频繁重新登录会影响体验。4.2 request 统一封装token 注入、错误码分流、超时兜底小程序原生 wx.request 用起来太裸每个页面都写一遍会非常散。我会在 utils/request.js 里封装一层统一做四件事拼接 BASE_URL、注入 token、按业务 code 分流、处理 HTTP 层异常。const BASE_URL https://meeting.example.com function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${path}, method: method, data: data, timeout: 10000, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success(res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data) } else if (res.data.code 401) { wx.removeStorageSync(token) // 这里统一跳转登录页避免每个页面自己处理 token 过期 } else { reject(res.data) } }, fail(err) { reject(err) } }) }) } module.exports { request }timeout 我习惯设 10 秒会议列表接口通常需要返回较多数据5 秒在大网络环境下偶尔不够。Authorization 放在 header 里后端 SpringMVC 用拦截器从 header 里取 token 解析用户身份这样每个业务接口不需要手动接收 token 参数代码干净很多。后端拦截器只是个人人都会配的基础设施但要注意一个坑小程序预览时第一次请求可能会走 OPTIONS 预检如果后端没有放行 OPTIONS 请求小程序端会一直报“跨域请求失败”。在拦截器里加一句如果请求方法是 OPTIONS直接返回 200。4.3 会议列表和详情页时间格式化与剩余名额渲染会议列表页是小程序的主页面。一组典型的返回数据里包含 startTime 和 endTime页面渲染时不能直接用后端字符串。最常见的场景是列表显示“06月01日 09:30”详情页显示“剩余名额 8/20”。function formatMeetingTime(value) { const date new Date(value.replace(/-/g, /)) const month date.getMonth() 1 const day date.getDate() const hours String(date.getHours()).padStart(2, 0) const minutes String(date.getMinutes()).padStart(2, 0) return ${month}月${day}日 ${hours}:${minutes} }这里必须解释 value.replace(/-/g, /) 这段iOS 的 JavaScriptCore 不认 “2024-06-01 09:30:00” 这种带横杠的日期字符串直接 new Date() 会得到 Invalid Date页面显示 “NaN月NaN日”。把横杠换成斜杠之后iOS 和 Android 都能正确解析。这个坑我在联调时踩过后来所有日期字段统一走后端格式化、前端只做显示没有再出问题。剩余名额的渲染也要做前置判断。如果为 0按钮置灰并显示“已满”如果当前时间已经超过会议的结束时间显示“已结束”按钮不可点击。列表页做一次当前时间对比即可详情页可以用 setInterval 每分钟刷新一次状态。4.4 预约按钮的防连点loading 状态必加小程序里用户连点两次预约按钮会发出两个相同请求。如果后端没有幂等处理就可能出现“一次点击占两个名额”的情况。后端靠唯一索引能拦住重复预约但前端仍然要加一层锁避免用户看到“请求中”时还能再点。Page({ data: { reserving: false }, async onReserveTap() { if (this.data.reserving) return this.setData({ reserving: true }) try { await request(/api/meeting/reserve, POST, { meetingId: this.data.meetingId }) wx.showToast({ title: 预约成功, icon: success }) } catch (err) { wx.showToast({ title: err.message || 预约失败, icon: none }) } finally { this.setData({ reserving: false }) } } })reserving 这个布尔值就是前端防连点开关。注意 finally 里每次都要复位不能只在校验失败时复位否则一次失败后按钮就永久不可点了。这里用 setData 而不是直接修改 this.data.reserving是为了让按钮的 disabled 属性与状态同步避免出现“逻辑上已锁定但按钮外观没有变化”的误导。按钮上加 disabled{{reserving}} 后用户点击时会直接走微信组件的禁用态双重保险。5. 避坑指南新手最容易翻车的五个点5.1 真机访问不了本地后端现象微信开发者工具里接口正常一用真机预览就报 request:fail页面白屏后端日志里一个请求都没有。原因小程序真机环境要求所有 request 请求必须走微信后台配置的合法域名并且必须是 HTTPS。开发时用局域网 IP 加端口访问本机 Tomcat这个地址不在合法域名列表里微信直接拦截。解决开发阶段在微信开发者工具右上角“详情 → 本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。这是官方提供的开发选项只对开发版和体验版生效发布上线前必须把后端接口部署到 HTTPS 域名并在小程序后台“开发管理 → 服务器域名”里加入 request 合法域名。如果你用的是云开发可以直接调 wx.cloud 的相关 API绕开域名校验但题目既然锁定 SSM 后端还是走传统 request 更贴近业务。5.2 顶部导航栏在不同机型上错位现象自定义导航栏在 iPhone 8 上按钮居中正常换到 iPhone 14 Pro 或带挖孔的安卓机上胶囊按钮挤压标题右侧按钮被裁掉。原因状态栏高度和胶囊按钮位置在不同机型上差异很大固定值 64 或 44 只对部分机型有效。一旦项目里选择了自定义导航栏导航栏高度就必须动态计算。解决用胶囊按钮的位置反推导航栏高度。给小程序根组件绑定一个 style把高度设为变量const winInfo wx.getWindowInfo() const menu wx.getMenuButtonBoundingClientRect() const statusBarHeight winInfo.statusBarHeight const navBarHeight (menu.top - statusBarHeight) * 2 menu.height这段公式的逻辑是菜单按钮顶部到状态栏底部的距离乘以 2加上按钮本身高度就是导航栏的总高度。它对大多数机型都成立因为胶囊按钮是微信自己渲染的它的位置天然适配了当前机型的刘海和挖孔。用这个值去设置 navigationStyle: custom 页面里的占位视图高度标题就不会跑偏。5.3 iOS 上时间显示 NaN相差 8 小时现象会议列表页在 iOS 真机上显示 “NaN月NaN日 09:30”但同一份代码在开发者工具里一切正常。还有另一种情况页面显示的时间比实际时间快了或慢了 8 小时。原因第一个坑是 iOS 的 Date 解析不认 “2024-06-01 09:30:00” 这种带横杠的字符串第二个坑是后端的 DateTime 通过网络传输到前端时被 JSON 库按 UTC 时区序列化前端没做时区转换直接 new Date导致偏移。解决前端统一用 replace(/-/g, /) 转成斜杠格式再解析这在上一章已经写过。后端确保 Jackson 的 ObjectMapper 配置了 timezone 为 GMT8并且在 JDBC URL 里已经写上 serverTimezoneAsia/Shanghai。这样从数据库到后端再到 JSON全程同一个时区前端渲染时不需要再加减 8 小时。最保险的做法是后端直接返回格式化好的字符串前端把字符串当纯文本显示彻底跳过 Date 解析。缺点是如果要计算“距开始还有多久”还得拿字符串再解析一次这时记得用斜杠兼容写法。5.4 预约超卖并发请求把剩余名额打成负数现象组织者设置一场会议名额为 1两个用户同时点击预约两个人都收到“预约成功”提示数据库里 remain_count 变成了 -1预约记录却有两条。原因Service 层写成“查询余量 → 判断 0 → 扣减”三步操作。两个请求同时读到 remain_count1同时通过判断同时执行扣减最终结果就是负值。这是典型的 check-then-act 竞态条件Java 代码不保证两条线程之间的先后顺序必须靠数据库的行锁来兜底。解决把判断条件写进 UPDATE 的 WHERE 子句用影响行数判断是否成功。SQL 就是我在 3.3 节给出的 deductRemain核心是 AND remain_count 0。如果影响行数为 0说明这个时刻已经没名额了直接抛“名额不足”。这条 SQL 执行时MySQL 会锁定对应的 meeting 行第二个请求必须等第一个事务提交后才能执行因此不会出现两个请求同时扣成功的情况。上线前建议用 JMeter 或 Postman 并发脚本验证一次设置名额为 1十线程同时请求预约接口最终只有一个人成功其余全部返回“名额不足”。5.5 数据库 8 小时断连接口突然报错现象系统跑了两周都很正常某天早上第一个访客打开小程序列表接口报 “Connection is not available, request timed out”刷新一次又恢复正常。原因MySQL 默认的连接空闲超时时间是 8 小时也就是 wait_timeout28800。当天晚上没有请求连接池里的连接一直空闲到早上被 MySQL 服务端主动断开。但连接池不知道连接已经失效仍把旧连接分配给第一个请求于是读操作直接超时。等连接池检测到异常并重建连接后第二个请求就正常了。解决连接池配置里开启空闲连接检测。以 Druid 为例这几项参数直接写进 spring 的 dataSource beanproperty nametestWhileIdle valuetrue/ property namevalidationQuery valueSELECT 1/ property nametimeBetweenEvictionRunsMillis value60000/ property nameminEvictableIdleTimeMillis value300000/testWhileIdle 设为 true 表示空闲连接在归还时会执行一次 validationQuery 验证是否存活timeBetweenEvictionRunsMillis 是每 60 秒扫描一次空闲连接minEvictableIdleTimeMillis 表示连接空闲超过 5 分钟才被回收。这套配置加上后长期无人访问的系统再也不会出现“每天早上第一次请求必挂”的玄学问题。6. 进阶把预约做成一次真正幂等的操作并验证并发预约接口的并发问题前面的方案已经解决了“超卖”但还剩下一个边界同一用户同一场会议的重复请求从前端防连点、后端唯一索引、事务回滚三个层面都做了拦截是不是就够了够是够了但我想在这个基础上再补最后一道保险——把接口设计成幂等无论同一个请求发多少次最终的数据状态都只有一份。具体做法分三步。第一步reservation 表保留唯一索引 uk_meeting_user这一步数据库层已经挡住了重复数据。第二步在 reserve 方法里捕获 DuplicateKeyException但不要吞掉异常而是把它翻译成用户可理解的错误信息同时因为整个方法在事务里扣减操作会自动回滚。第三步前端把预约按钮的请求加一个业务幂等键比如每次进入详情页生成一个随机串第一次预约成功后把键存起来按钮置灰。这样即使用户杀掉小程序再重新打开看到的状态也是“已预约”不会产生歧义。验证这套方案有没有问题我一般不用 UI 手工点而是直接压接口。拿 JMeter 建一个线程组十个线程同时调 /api/meeting/reserve参数是同一个 meetingId 和不同 userId断言表现按三种预期看成功数不超过剩余名额每个 userId 最多成功一次remain_count 最终等于 capacity 减去成功数。如果第一条不满足说明 UPDATE 的原子扣减没生效如果第二条不满足说明唯一索引或事务回滚配错了两条都满足这个预约接口就扛得住真实场景的上线流量。我自己的习惯是凡是涉及“剩余名额”的业务都把唯一索引和原子扣减放在同一个事务里并且把 SQL 的武器说明写在代码注释里防止后来维护的人觉得“先查再改”更方便就顺手改成反面写法。曾经有一次我图省事在另一个活动报名系统里用了先查后改结果用户连点两次预约一个名额占了两条记录最后只能写脚本手动清数据。那次教训之后我再没有在这种场景下相信程序员的自觉——所有能靠数据库约束解决的并发问题都不要指望代码顺序。这套会议发布与预约系统技术上没有高深的东西难点全在数据边界和用户重复操作上。把并发扣减、时间格式化、导航栏适配这三关过了项目就完成了八成。剩下的靠小程序开发者工具和真机分别跑一遍把日志看熟你会比照抄代码的人更快理解框架之间是怎么协作的。希望帮到你。本文还有配套的精品资源点击获取
返回列表