学生选课系统实战:微信小程序与高并发防超卖设计方案

发布时间:2026/9/11 14:42:43
学生选课系统实战:微信小程序与高并发防超卖设计方案 又到选课季。每年这个时候学校教务系统的服务器都会被几千名学生同时按刷新选课按钮点下去转个圈然后弹出一句“系统繁忙”——这种情况我见得实在太多了。做学生选课系统最容易被误以为“难”的是界面课程列表、周课表、已选课程、退课确认……这些用微信小程序做起来并不复杂花两天就能把页面搭出来。真正难的是选课高峰期几千人同时点按钮的时候怎么保证课程不超卖、学生不重复选、库存和选课记录不出错。这篇文章把我做过的基于微信小程序的学生选课系统的完整思路整理出来从技术选型、登录鉴权、并发控制、界面适配到抓包联调、提审发布、上线排错每个环节都讲清楚“为什么这样做”。如果你正在做课程设计、毕业设计或者学校信息化团队想自建一套选课系统这篇的内容可以直接拿来参考。1. 整体设计为什么用微信小程序承载选课业务1.1 前端选型决策小程序对比App和H5我最早考虑过三个方向原生App、移动端H5、微信小程序。对比了一圈之后选课这个场景用微信小程序几乎是压倒性的优势。先说原生App。学生选课集中在学期初的几天平时打开频率很低让全校学生为了选课专门装一个App装机成本太高而且iOS和Android两套开发维护成本也不低。H5倒是免安装但入口太浅学生可能记不住网址每次都要从通知里翻链接。小程序夹在中间反而最合适微信里搜一下或者从公众号菜单点一下就能进不需要安装用完即走下次选课再打开。还有一个关键点选课系统天然需要知道“这个人是谁”。微信小程序里用wx.login换 openid识别用户非常方便省掉了账号密码登录的交互成本。学生的学号绑定一次之后后续进入系统就是自动登录状态。1.2 原生开发还是uni-app这次我选原生的理由选题阶段经常有人问用 uni-app 还是微信原生我这次的答案比较明确如果只做微信小程序这一个端选原生。TDesign或者Vant Weapp这类原生组件库可以直接用调试的时候报错信息也更直接。uni-app 的优势在于一套代码多端复用比如你同时要出支付宝小程序、抖音小程序、H5那确实省事。但代价是它在微信端的自定义导航栏适配、组件生命周期这些细节上多了一层封装遇到问题排查链路更长。我们这次项目就只有微信小程序一个端后端接口是独立的 Spring Boot 服务不需要跟 H5 复用前端代码所以直接用原生写。开发效率并不慢而且wx:开头的模板语法和页面生命周期理解起来非常直观对新手也友好。1.3 后端与数据表设计后端我用的 Spring Boot MyBatis-Plus数据库 MySQL缓存用的 Redis。选课系统虽然看起来只是“查课程、选课程、退课程”但数据表设计直接决定并发控制怎么写。核心表就这几张表名用途关键字段student学生信息student_no(学号), name, class_name, wx_openidcourse课程信息id, course_name, teacher, credit, weekday, time_slot, locationcourse_stock课程容量course_id, total_capacity, selected_countselect_record选课记录id, student_id, course_id, create_time, statuscourse_selection_config选课开关配置id, phase_name, start_time, end_time这里要特别说下select_record表我在student_id course_id上加了唯一索引。这个唯一索引看着不起眼却是后面防重复选课的最后一道防线任何业务代码漏判数据库层面都能兜住。2. 登录、学号绑定与头像昵称的正确落地2.1 wx.login 换取 openid 的完整流程微信小程序登录的标准流程是小程序端调用wx.login拿到一个临时 code把 code 发给后端后端用 code 调微信接口换取 openid 和 session_key然后用 openid 去查或建学生记录签发自定义 token 返回给前端。[小程序] wx.login() - 拿到 code [小程序] 请求后端 POST /api/auth/login { code } [后端] 调用 jscode2session 接口换取 openid session_key [后端] 根据 openid 查 student 表没有则创建新用户 [后端] 生成 token 返回前端 [小程序] 把 token 存入 wx.storage后续请求头带上前端代码大致是这样wx.login({ success: async (res) { if (res.code) { const result await request({ url: /api/auth/login, method: POST, data: { code: res.code } }); if (result.code 0) { wx.setStorageSync(token, result.data.token); // 检查是否已经绑定学号 if (!result.data.bound) { wx.navigateTo({ url: /pages/bind/bind }); } else { wx.switchTab({ url: /pages/index/index }); } } } } });2.2 头像昵称填写从 getUserProfile 到 chooseAvatar很多老教程会让你用wx.getUserProfile拿头像昵称但这个接口在2022年10月之后就调整了现在个人版小程序直接调用基本拿不到真实头像昵称返回的都是灰色默认头像和“微信用户”。正确做法是用官方推荐的“头像昵称填写能力”头像用button的open-typechooseAvatar昵称用input组件的typenickname。这两样东西微信会拉出系统自带的头像选择器和昵称填充建议体验比旧方案还好。button classavatar-wrapper open-typechooseAvatar bind:chooseavataronChooseAvatar image src{{avatarUrl}} / /button input typenickname placeholder请输入昵称 bindinputonNicknameInput /头像选择回来是一个临时文件路径需要调用wx.uploadFile传到后端后端存到自己的文件服务或云存储里。昵称就是普通文本直接提交即可。2.3 学号绑定与token存储的细节选课系统必须要绑学号否则没法跟教务数据关联。我的做法是首次登录后跳转到绑定页学生输入学号和教务密码后端拿学号密码去教务系统验证学校内部一般有统一认证接口验证通过后把学号和 openid 关联到 student 表。这里有几个细节比较容易被忽略session_key绝对不能下发到前端它只在后端保存而且用完应该主动销毁或定期失效。token 建议设置有效期选课系统虽然使用频率不高但一学期要登录好几次有效期可以设长一点比如7天。换绑学号的场景要留个入口有的学生会输错学号管理员也要能解绑。3. 并发选课与退课这道坎过不去系统上线必崩3.1 三种防超卖方案的取舍选课的核心问题不是界面是并发。想象一下一门热门课容量只有60人结果同时有300个人在抢如果你用“先查剩余名额再插入记录”的常规逻辑100%会出现超卖。查的时候都是59人已选然后300个人同时插入最后一门60人的课选进去三百多人。我评估了三种方案方案实现方式优点缺点数据库行锁选课前先SELECT ... FOR UPDATE锁住课程行实现简单绝对可靠并发高时锁等待严重数据库压力大Redis原子操作用 Redis 的DECR或 Lua 脚本扣减库存性能好能够承载高并发要处理 Redis 和 MySQL 数据一致性问题唯一索引兜底选课记录表加唯一索引冲突则拒绝逻辑最简单数据库层面兜底只能防重复不能防超卖单纯用其中任何一种都有缺陷。行锁在几千人同时抢课的时候会把数据库连接池打满Redis 扣减如果不用 Lua 脚本检查和扣减不是原子操作还是会超卖唯一索引只能保证同一个人不重复选没法控制总人数。3.2 RedisLua唯一索引的组合方案落地我最终选择了组合方案Redis 用 Lua 脚本做库存预扣减扛住高并发MySQL 的select_record唯一索引做防重复兜底数据库选课记录插入和 Redis 回滚用事务消息补偿。Lua 脚本长这样-- KEYS[1]: course_stock:{courseId} -- ARGV[1]: studentId if redis.call(exists, KEYS[1]) 0 then return nil end local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then redis.call(decr, KEYS[1]) return stock end return 0后端选课接口核心逻辑Transactional public Result selectCourse(Long studentId, Long courseId) { // 1. Redis 预扣减 Long stock redisTemplate.execute(stockScript, Collections.singletonList(course_stock: courseId), studentId.toString()); if (stock null) { return Result.fail(课程不存在); } if (stock 0) { return Result.fail(课程容量已满); } // 2. 插入选课记录唯一索引兜底 try { selectRecordMapper.insert(new SelectRecord(studentId, courseId, 1)); } catch (DuplicateKeyException e) { // 重复选课回滚 Redis 库存 redisTemplate.opsForValue().increment(course_stock: courseId); return Result.fail(你已经选过这门课了); } // 3. 异步更新 MySQL 课程已选数量 courseStockMapper.incrementSelected(courseId); return Result.success(); }Redis 里的初始库存从 MySQL 的course_stock表加载在选课阶段开始前一次性写入选课结束再核对一次用实际数据库记录数校准。3.3 退课和容量恢复的逻辑退课看起来比选课简单但处理不好同样出问题。学生退课后Redis 库存要加回来MySQL 的选课记录要删掉或标记为已退已选数量要减。这三件事不是同时发生的中间任何一个环节失败都会造成库存对不上。我的做法是退课先更新 MySQLselect_record的状态为退课然后删 Redis 或者INCR恢复库存最后更新course_stock的已选数量。如果 Redis 恢复失败通过定时任务扫描对账以 MySQL 实际选课记录数为准来校准 Redis 库存。这里有一个产品层面的细节值得提一下退课后名额不是实时放出的我设置了5分钟的延迟释放。因为很多学生退课之后立刻又后悔想选回来立即释放容易被同一批学生反复占名额导致真正想选的人一直选不上。3.4 压测验证上线前我用 JMeter 做了一轮压测模拟 200 个并发用户同时选同一门课。第一次压测就发现了问题数据库连接池默认只有 10 个大量请求直接超时报错率飙到 30%。后来把连接池调到 50Redis 连接数也做了相应的调整才稳定下来。压测结果给了几个关键参数课程初始容量 60压测 300 并发请求最终选课成功人数精确 60没有超卖。重复选课请求全部被唯一索引拦截没有出现同一学生两条记录。接口平均响应时间 800msP95 在 1.2s 左右可接受。压测这件事一定要放在上线前做我见过太多项目上线当天崩在选课上就是因为从来没人想过这门课会有多少人同时抢。4. 课程表页面、导航栏适配与搜索交互4.1 顶部导航栏高度的坑微信小程序的顶部导航栏和普通网页不一样不是简单一行height: 44px就完事。iPhone 有刘海屏状态栏Android 各家状态栏高度也不一样如果直接在页面顶部写死高度iPhone 14 Pro 上会跟胶囊按钮右上角那三个点重叠。正确做法是动态计算const systemInfo wx.getWindowInfo(); const menuButton wx.getMenuButtonBoundingClientRect(); const statusBarHeight systemInfo.statusBarHeight; const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height; const totalNavHeight statusBarHeight navBarHeight;拿到totalNavHeight之后自定义导航栏的容器高度用这个值撑开这样不管是刘海屏还是普通屏都能完美避开胶囊按钮。这个计算逻辑建议写成一个公共工具函数所有页面统一调用而不是每个页面复制一遍。4.2 横向滚动的周课表设计课程表是选课系统的门面我见过很多实现方案最简单的做法是放一张切好的图片但这没法做交互。我的方案是用scroll-view横向滚动 网格布局外层scroll-view设置scroll-x宽度撑到整个屏幕。内部一个宽 7 列周一到周日的网格每列宽 120rpx用flex布局。每天分12个时间段每个课程卡片绝对定位到对应的时间格子。课程卡片点击之后弹出详情展示课程名、老师、学分、剩余名额下面放“选课”按钮。选课确认弹窗里用radio-group列出该课程的所有时段让学生确认选的是哪一节这个交互细节比直接提交体验好很多。课程表渲染时有个容易出错的点课程的weekday字段和 JavaScript 数组下标不一样周日是0周一是1。这块映射关系写错整个课程表全乱。4.3 搜索课程与软键盘遮挡处理选课系统必须有搜索功能不然学生找一门选修课要翻好几页。我在课程列表页顶部放了搜索框支持按课程名和老师名模糊搜索后端用LIKE查询搞定。这里遇到一个真实问题安卓手机上软键盘弹起来会遮住下方的课程查询结果用户没法看到自己输入之后的反馈。解决办法是给input组件加上adjust-position和cursor-spacing属性input classsearch-input placeholder输入课程名或老师名 adjust-position{{true}} cursor-spacing20 bindinputonSearchInput confirm-typesearch bindconfirmonSearchConfirm /cursor-spacing表示光标与键盘的距离设成 20 让页面自动上推。如果还不够可以用wx.onKeyboardHeightChange监听键盘高度手动调整滚动容器位置。这两种方式我都在项目里用了安卓和 iOS 上表现都正常。5. 从开发者工具到线上联调、抓包与发布审核5.1 本地联调配置开发阶段第一个要处理的问题是域名校验。微信开发者工具默认只允许请求配置过的合法域名本地开发时后端跑在http://localhost:8080直接请求会报“不在以下 request 合法域名列表中”。解决办法有两个在开发者工具右上角“详情 - 本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。或者在后端配置 HTTPS 证书把域名加到小程序后台的 request 合法域名里。开发前期用第一个方案最省事但要注意这只针对开发者工具真机预览如果不勾选“开发环境不校验请求域名”一样会被拦住。5.2 用代理工具抓取小程序请求排查数据联调阶段经常要排查前端和后端的数据问题这时候最有效的办法就是抓包看请求。微信开发者工具自带的 Network 面板能看一部分但某些场景比如真机上出现的问题需要把手机的请求代理到电脑上。我用的是 Burp Suite 来做代理抓包方法不复杂电脑和手机连同一个局域网手机 Wi-Fi 设置里把 HTTP 代理指到电脑 IP 和 Burp 监听的端口然后在手机微信里打开小程序所有请求都会经过 Burp能看到完整的 URL、请求头、请求体和响应数据。这个过程要在自己开发调试的设备上做目的是排查自己后端接口的返回数据是否正确属于正常的开发调试手段。抓包过程中我发现过一个有意思的问题后端接口返回的课程时间字段是08:00:00这种字符串但小程序端new Date(08:00:00)在 iOS 上解析会直接返回Invalid DateAndroid 上却正常。这种跨端差异不抓包很难一眼看出来后来统一改成后端返回时间戳前端再格式化问题解决。5.3 提审与发布流程中容易忽略的检查项小程序开发完要发布必须走微信审核第一次提审被拒的概率其实很高。我整理了几个容易踩的坑隐私协议微信现在强制要求填写用户隐私保护指引尤其是我们涉及学号、姓名这些个人信息必须在mp.weixin.qq.com后台的“设置 - 服务内容声明 - 用户隐私保护指引”里声明收集了哪些信息、用途是什么。没填或者填写不完整提审必被拒。登录前置如果小程序打开就强制要求登录审核人员可能因为无法浏览内容而拒绝。我的处理方式是未登录用户允许浏览课程列表和课表点选课按钮时才提示登录绑学号。这既方便了体验也过了审核要求。类目选择选课系统属于教育类但个人开发者或学校内部使用时可以选择“工具 - 效率”或者“教育 - 教育信息服务”等更合适的类目。不同类目对资质要求不一样学校自用系统如果没有办学资质材料要提前想清楚选哪个类目能过审。发布流程比较简单开发者工具点击“上传”在后台“版本管理”里把上传的代码提交审核审核通过后点击“发布”。整个周期快的话几小时慢的话一两天学期初选课一定要提前至少一周走完发布流程不要卡着选课当天提审。6. 上线后的排错记录与几点心得6.1 不同机型上的布局与字体适配问题上线第一天就收到反馈有同学的 iPhone SE 上课程表右边两列的课程显示不全还有人截图说自己的选课按钮文字显示为两行被截断。排查之后发现两个原因。第一个是课程表的列宽单位用错了我在某些地方用了px而不是rpxiPhone SE 屏幕宽度小整块内容被撑出屏幕外。全局检查一遍把所有宽度类样式都改成rpx后正常。第二个原因是部分用户手机系统字号调得比较大小程序的rpx虽然会自动缩放但按钮文字用了固定字号导致换行。解决方法是按钮文字改成自适应用flextext-overflow: ellipsis处理超长文本。这类机型适配问题真机调试时很难把所有设备都测一遍我的建议是样式统一用rpx文字不要写死font-size多留点弹性空间至少保证主流机型不出大问题。6.2 已选课列表刷新与缓存不一致的处理选课成功后用户在“我的课表”里看到的可能还是未选状态这是因为页面用了本地缓存选课成功后没有及时更新。后来我把选课和退课的成功回调里都加上了wx.removeStorageSync(myCoursesCache)并且在onShow生命周期里重新拉取已选列表保证页面每次可见时都是最新数据。这个问题的根源是数据一致性但深一层看选课这种高频操作不建议过度依赖缓存宁可每次onShow都请求一次接口。选课系统的数据变化频率本来就高缓存带来的性能提升有限反而引入展示不一致的坑。6.3 课程数据导出Excel的实现思路运营老师提了一个需求选课结束后要把选课名单导出成 Excel。小程序端不能直接生成 Excel 文件我的做法是后端用 EasyExcel 生成.xlsx文件返回下载链接前端用wx.downloadFile下载然后wx.openDocument打开文件预览。wx.downloadFile({ url: https://your-api.com/api/export/select-list?courseIdxxx, header: { Authorization: Bearer ${token} }, success(res) { if (res.statusCode 200) { wx.openDocument({ filePath: res.tempFilePath, fileType: xlsx, showMenu: true, success() { console.log(打开成功); } }); } } });showMenu: true记得加上这样用户在预览页面右上角可以转发或保存文件不然只能看不能导出。另外wx.downloadFile同样受合法域名限制导出接口的域名也要配进downloadFile合法域名里这个容易被忽略。6.4 最后说几点个人体会项目做完回头看最值钱的不是界面写了多少个页面而是并发选课那套设计。很多校园系统上线即崩不是代码写得差是压根没想过最坏情况下的流量。选课系统一定要做压测一定要有数据库层面的唯一索引兜底Redis、消息队列这些中间件可以简化但数据一致性的底线不能丢。还有一点别把业务逻辑堆在小程序端。我见过有人把选课判断写在wx前端结果被懂技术的同学绕过前端直接调接口把库存刷爆了。所有校验必须放后端前端只是交互展示。如果你也正在做类似的微信小程序课程设计或校园系统建议先把“并发控制”“登录鉴权”“机型适配”这三个环节想清楚再动手写代码。界面可以慢慢调但这三件事没想好上线那天会很痛苦。