学生选课系统实战:微信小程序与高并发防超卖设计方案
2026/9/11 14:42:28 网站建设 项目流程

又到选课季。每年这个时候,学校教务系统的服务器都会被几千名学生同时按刷新,选课按钮点下去转个圈,然后弹出一句“系统繁忙”——这种情况我见得实在太多了。做学生选课系统,最容易被误以为“难”的是界面:课程列表、周课表、已选课程、退课确认……这些用微信小程序做起来并不复杂,花两天就能把页面搭出来。真正难的是选课高峰期几千人同时点按钮的时候,怎么保证课程不超卖、学生不重复选、库存和选课记录不出错。

这篇文章把我做过的基于微信小程序的学生选课系统的完整思路整理出来,从技术选型、登录鉴权、并发控制、界面适配到抓包联调、提审发布、上线排错,每个环节都讲清楚“为什么这样做”。如果你正在做课程设计、毕业设计,或者学校信息化团队想自建一套选课系统,这篇的内容可以直接拿来参考。

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_openid
course课程信息id, course_name, teacher, credit, weekday, time_slot, location
course_stock课程容量course_id, total_capacity, selected_count
select_record选课记录id, student_id, course_id, create_time, status
course_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月之后就调整了,现在个人版小程序直接调用基本拿不到真实头像昵称,返回的都是灰色默认头像和“微信用户”。

正确做法是用官方推荐的“头像昵称填写能力”:头像用buttonopen-type="chooseAvatar",昵称用input组件的type="nickname"。这两样东西微信会拉出系统自带的头像选择器和昵称填充建议,体验比旧方案还好。

<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar"> <image src="{{avatarUrl}}" /> </button> <input type="nickname" placeholder="请输入昵称" bindinput="onNicknameInput" />

头像选择回来是一个临时文件路径,需要调用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 Redis+Lua+唯一索引的组合方案落地

我最终选择了组合方案: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%。后来把连接池调到 50,Redis 连接数也做了相应的调整,才稳定下来。

压测结果给了几个关键参数:

  • 课程初始容量 60,压测 300 并发请求,最终选课成功人数精确 60,没有超卖。
  • 重复选课请求全部被唯一索引拦截,没有出现同一学生两条记录。
  • 接口平均响应时间 800ms,P95 在 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-positioncursor-spacing属性:

<input class="search-input" placeholder="输入课程名或老师名" adjust-position="{{true}}" cursor-spacing="20" bindinput="onSearchInput" confirm-type="search" bindconfirm="onSearchConfirm" />

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 Date,Android 上却正常。这种跨端差异不抓包很难一眼看出来,后来统一改成后端返回时间戳,前端再格式化,问题解决。

5.3 提审与发布流程中容易忽略的检查项

小程序开发完要发布必须走微信审核,第一次提审被拒的概率其实很高。我整理了几个容易踩的坑:

隐私协议:微信现在强制要求填写用户隐私保护指引,尤其是我们涉及学号、姓名这些个人信息,必须在mp.weixin.qq.com后台的“设置 -> 服务内容声明 -> 用户隐私保护指引”里声明收集了哪些信息、用途是什么。没填或者填写不完整,提审必被拒。

登录前置:如果小程序打开就强制要求登录,审核人员可能因为无法浏览内容而拒绝。我的处理方式是:未登录用户允许浏览课程列表和课表,点选课按钮时才提示登录绑学号。这既方便了体验,也过了审核要求。

类目选择:选课系统属于教育类,但个人开发者或学校内部使用时,可以选择“工具 -> 效率”或者“教育 -> 教育信息服务”等更合适的类目。不同类目对资质要求不一样,学校自用系统如果没有办学资质材料,要提前想清楚选哪个类目能过审。

发布流程比较简单:开发者工具点击“上传”,在后台“版本管理”里把上传的代码提交审核,审核通过后点击“发布”。整个周期快的话几小时,慢的话一两天,学期初选课一定要提前至少一周走完发布流程,不要卡着选课当天提审。

6. 上线后的排错记录与几点心得

6.1 不同机型上的布局与字体适配问题

上线第一天就收到反馈:有同学的 iPhone SE 上,课程表右边两列的课程显示不全;还有人截图说自己的选课按钮文字显示为两行,被截断。

排查之后发现两个原因。第一个是课程表的列宽单位用错了,我在某些地方用了px而不是rpx,iPhone SE 屏幕宽度小,整块内容被撑出屏幕外。全局检查一遍,把所有宽度类样式都改成rpx后正常。第二个原因是部分用户手机系统字号调得比较大,小程序的rpx虽然会自动缩放,但按钮文字用了固定字号,导致换行。解决方法是按钮文字改成自适应,用flex+text-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?courseId=xxx', 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前端,结果被懂技术的同学绕过前端直接调接口,把库存刷爆了。所有校验必须放后端,前端只是交互展示。

如果你也正在做类似的微信小程序课程设计或校园系统,建议先把“并发控制”“登录鉴权”“机型适配”这三个环节想清楚,再动手写代码。界面可以慢慢调,但这三件事没想好,上线那天会很痛苦。

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

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

立即咨询