MFC选课系统高并发优化:SQLite原子扣减与事务实战
简介这份增进版学生选课系统基于MFC框架开发面向计算机专业学生、课程设计开发者及需要教学案例的高校教师用于解决传统选课系统操作复杂、响应慢、功能不全等痛点。资源包共85个文件约3.11MB以h头文件与cpp源文件为核心配合obj编译产物、bmp与ico界面素材、mdb数据库及dsp/dsw工程文件构成一套可直接编译运行的VC6工程。系统覆盖用户管理、课程管理、选课流程优化、数据统计、消息推送与权限验证等模块学生、教师、管理员分角色操作支持预选、正选、补选与退选等阶段。已有255人学习下载适合作为MFC课程设计、数据库编程与Windows应用开发的实战参考读者可从中获取完整工程结构、类设计思路与选课业务逻辑实现便于二次开发与功能扩展。1. 增进版学生选课系统从 MFC 对话框骨架到能扛住两百人同时抢课如果你手头有一个“增进版学生选课系统”的课程设计或小团队内部工具要求用 MFC 做大概率会遇到一个尴尬局面网上能搜到的 MFC 选课系统源码九成是拖几个编辑框、连一个 Access 数据库、点按钮就INSERT的玩具。单机跑没问题一旦放到机房让两百个学生同时点“选课”要么卡死要么出现同一门课被选超人数。这篇笔记不讲“MFC 是什么”而是把一套能真正跑起来、能解释清楚数据一致性怎么保证的 MFC 选课系统拆开界面用 MFC 对话框程序数据层用 SQLite 或 SQL Server核心难点在选课事务和余量控制。适合正在做课程设计、想拿高分或者需要给内部做一个小型选课工具的人。下面从工程结构开始一步步把“增进版”这三个字落到实处。2. 工程骨架与数据层选型为什么不用 Access 而用 SQLite2.1 MFC 对话框程序的四类文件与选课系统的映射MFC 对话框程序常见结构是App、Dlg、stdafx、targetver这几类文件。放到选课系统里我一般这样切CSelectionApp只负责启动和初始化数据库连接不写业务。CLoginDlg登录窗口区分学生和教师角色。CStudentDlg学生主界面左侧课程列表右侧已选列表。CTeacherDlg教师主界面发布课程、查看选课名单。CDbHelper单例类封装所有 SQL 执行不把CDatabase散落在各个对话框里。这样切的好处是后面加“退课”“导出名单”“选课时间控制”时不用去动界面代码。很多课程设计翻车就翻在把 SQL 语句直接写在按钮响应函数里改一个字段要翻五个文件。2.2 用 SQLite 替代 Access 的三个实际理由Access 在 MFC 里通过 ODBC 连接单机没问题但并发一上来就锁库。SQLite 同样轻量但支持 WAL 模式读多写少的选课场景更稳。具体差异对比项Access ODBCSQLite 原生 API并发写容易整库锁WAL 模式下读写不互斥部署需要装驱动一个 dll 加一个 db 文件事务支持但慢支持且快余量控制靠代码判断可用UPDATE ... WHERE remaining 0原子操作我一般会选 SQLite因为选课系统的核心矛盾是“同一时刻大量学生抢同一门课”需要数据库层面帮我们做原子扣减而不是在 C 里先查再改。2.3 建表语句与余量字段的设计课程表里不要只存“已选人数”要存“容量”和“已选人数”两个字段余量用计算列或查询时算。但为了原子扣减我习惯直接加一个remaining字段初始等于容量每次选课成功减一。-- 课程表容量、余量、选课开关分开 CREATE TABLE course ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, teacher TEXT NOT NULL, capacity INTEGER NOT NULL DEFAULT 0, remaining INTEGER NOT NULL DEFAULT 0, is_open INTEGER NOT NULL DEFAULT 1, -- 1 可选0 关闭 created_at TEXT DEFAULT (datetime(now, localtime)) ); -- 选课记录表学生和课程唯一约束防止重复选 CREATE TABLE selection ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT NOT NULL, course_id INTEGER NOT NULL, selected_at TEXT DEFAULT (datetime(now, localtime)), UNIQUE(student_no, course_id) );remaining字段是“增进版”的关键。普通版本只查COUNT(*)判断是否满员并发下两个线程同时查到 99 人都以为还能选结果变成 101。有了remaining扣减时用UPDATE course SET remaining remaining - 1 WHERE id ? AND remaining 0数据库保证只有一行被更新返回受影响行数为 1 才算选课成功。2.4 在 MFC 中初始化 SQLite 连接MFC 本身不直接带 SQLite需要把sqlite3.h、sqlite3.c和sqlite3.dll放进工程。我一般把sqlite3.c直接加入项目编译避免 dll 版本问题。// DbHelper.h #pragma once #include sqlite3.h #include string class CDbHelper { public: static CDbHelper Instance(); bool Open(const std::string dbPath); void Close(); sqlite3* GetDb() { return m_db; } private: CDbHelper() : m_db(nullptr) {} sqlite3* m_db; }; // DbHelper.cpp CDbHelper CDbHelper::Instance() { static CDbHelper inst; return inst; } bool CDbHelper::Open(const std::string dbPath) { int rc sqlite3_open(dbPath.c_str(), m_db); if (rc ! SQLITE_OK) { return false; } // 开启 WAL 模式提升并发读性能 sqlite3_exec(m_db, PRAGMA journal_modeWAL;, nullptr, nullptr, nullptr); // 设置忙等待避免瞬时锁冲突直接失败 sqlite3_busy_timeout(m_db, 3000); return true; }PRAGMA journal_modeWAL让读写可以同时进行busy_timeout设 3000 毫秒意思是如果数据库被写锁占用等 3 秒再重试而不是立刻报错。这两个参数在单机选课系统里能挡掉大部分“database is locked”的玄学问题。3. 选课核心逻辑原子扣减与事务边界3.1 选课按钮背后的完整事务流程学生点“选课”按钮不能只执行一条 INSERT。完整流程是检查课程是否开放is_open 1。检查该学生是否已经选过这门课靠 UNIQUE 约束兜底。原子扣减余量UPDATE course SET remaining remaining - 1 WHERE id ? AND remaining 0 AND is_open 1。如果上一步受影响行数为 1再插入选课记录。如果插入失败比如唯一约束冲突回滚余量。这五步必须在一个事务里。SQLite 默认每条语句自动提交要用BEGIN IMMEDIATE手动开事务。bool SelectCourse(const std::string studentNo, int courseId, std::string errMsg) { sqlite3* db CDbHelper::Instance().GetDb(); char* err nullptr; // 开启立即事务获取写锁避免并发下先读后写 if (sqlite3_exec(db, BEGIN IMMEDIATE;, nullptr, nullptr, err) ! SQLITE_OK) { errMsg err ? err : begin failed; sqlite3_free(err); return false; } // 第一步原子扣减余量 std::string sqlUpdate UPDATE course SET remaining remaining - 1 WHERE id ? AND remaining 0 AND is_open 1;; sqlite3_stmt* stmt nullptr; sqlite3_prepare_v2(db, sqlUpdate.c_str(), -1, stmt, nullptr); sqlite3_bind_int(stmt, 1, courseId); int rc sqlite3_step(stmt); int affected sqlite3_changes(db); sqlite3_finalize(stmt); if (rc ! SQLITE_DONE || affected ! 1) { sqlite3_exec(db, ROLLBACK;, nullptr, nullptr, nullptr); errMsg 课程已满或未开放; return false; } // 第二步插入选课记录 std::string sqlInsert INSERT INTO selection(student_no, course_id) VALUES(?, ?);; sqlite3_prepare_v2(db, sqlInsert.c_str(), -1, stmt, nullptr); sqlite3_bind_text(stmt, 1, studentNo.c_str(), -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 2, courseId); rc sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc ! SQLITE_DONE) { // 插入失败回滚余量 sqlite3_exec(db, ROLLBACK;, nullptr, nullptr, nullptr); errMsg 重复选课或数据异常; return false; } sqlite3_exec(db, COMMIT;, nullptr, nullptr, nullptr); return true; }BEGIN IMMEDIATE和普通BEGIN的区别在于它立刻尝试获取写锁而不是等到第一条写语句才获取。这样两个并发选课请求进来一个拿到写锁另一个直接等待或失败不会出现两个都读到remaining 1然后都去扣减的情况。sqlite3_changes返回上一条 UPDATE 实际修改的行数只有等于 1 才说明扣减成功。3.2 退课时的余量回补与事务处理退课逻辑比选课简单但也要事务。先删除选课记录如果删除成功再把余量加回去。顺序不能反否则先加余量再删记录删记录失败就多出一个名额。bool DropCourse(const std::string studentNo, int courseId, std::string errMsg) { sqlite3* db CDbHelper::Instance().GetDb(); sqlite3_exec(db, BEGIN IMMEDIATE;, nullptr, nullptr, nullptr); // 先删记录 std::string sqlDel DELETE FROM selection WHERE student_no ? AND course_id ?;; sqlite3_stmt* stmt nullptr; sqlite3_prepare_v2(db, sqlDel.c_str(), -1, stmt, nullptr); sqlite3_bind_text(stmt, 1, studentNo.c_str(), -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 2, courseId); sqlite3_step(stmt); int affected sqlite3_changes(db); sqlite3_finalize(stmt); if (affected ! 1) { sqlite3_exec(db, ROLLBACK;, nullptr, nullptr, nullptr); errMsg 未找到选课记录; return false; } // 再回补余量但不能超过容量 std::string sqlUp UPDATE course SET remaining remaining 1 WHERE id ? AND remaining capacity;; sqlite3_prepare_v2(db, sqlUp.c_str(), -1, stmt, nullptr); sqlite3_bind_int(stmt, 1, courseId); sqlite3_step(stmt); sqlite3_finalize(stmt); sqlite3_exec(db, COMMIT;, nullptr, nullptr, nullptr); return true; }remaining capacity这个条件防止教师手动改过容量后退课把余量加超。如果教师把容量从 100 改成 80而已经选了 90 人退课时余量不应该超过 80。3.3 用 MFC 列表控件展示课程与已选状态学生界面一般用两个CListCtrl左边所有课程右边已选课程。初始化时用LVS_REPORT样式插入列。// 初始化课程列表 m_courseList.InsertColumn(0, _T(课程编号), LVCFMT_LEFT, 80); m_courseList.InsertColumn(1, _T(课程名称), LVCFMT_LEFT, 150); m_courseList.InsertColumn(2, _T(教师), LVCFMT_LEFT, 100); m_courseList.InsertColumn(3, _T(余量), LVCFMT_LEFT, 60); m_courseList.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); // 查询并填充 std::string sql SELECT id, name, teacher, remaining FROM course WHERE is_open 1;; sqlite3_stmt* stmt nullptr; sqlite3_prepare_v2(db, sql.c_str(), -1, stmt, nullptr); int row 0; while (sqlite3_step(stmt) SQLITE_ROW) { int id sqlite3_column_int(stmt, 0); const unsigned char* name sqlite3_column_text(stmt, 1); const unsigned char* teacher sqlite3_column_text(stmt, 2); int remaining sqlite3_column_int(stmt, 3); m_courseList.InsertItem(row, CString(id)); m_courseList.SetItemText(row, 1, CString(name)); m_courseList.SetItemText(row, 2, CString(teacher)); m_courseList.SetItemText(row, 3, CString(remaining)); row; } sqlite3_finalize(stmt);LVS_EX_FULLROWSELECT让整行可选中LVS_EX_GRIDLINES显示网格线。填充时注意sqlite3_column_text返回的是 UTF-8如果 MFC 工程用 Unicode 字符集需要转成CString时指定编码否则中文会乱码。我一般写一个Utf8ToCString辅助函数用MultiByteToWideChar转。4. 避坑与排查MFC 选课系统最常见的五个翻车点4.1 现象选课按钮点下去没反应界面卡死原因通常是 SQLite 的busy_timeout没设或者事务没提交就退出函数。如果BEGIN IMMEDIATE之后某条语句失败没有执行ROLLBACK或COMMIT数据库连接会一直持有写锁后续所有写操作都卡住。解决每个事务分支都要有明确的ROLLBACK或COMMIT。我习惯用一个小 RAII 类构造时BEGIN析构时如果没提交就ROLLBACK。另外sqlite3_busy_timeout设 3000 毫秒给并发一点缓冲。4.2 现象中文课程名显示成乱码MFC 默认 Unicode 工程SQLite 存的是 UTF-8。直接CString(sqlite3_column_text(...))会按 ANSI 解释中文变问号。解决写转换函数。CString Utf8ToCString(const char* utf8) { int len MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); wchar_t* buf new wchar_t[len]; MultiByteToWideChar(CP_UTF8, 0, utf8, -1, buf, len); CString str(buf); delete[] buf; return str; }插入中文时反过来用CStringA转 UTF-8或者直接用sqlite3_bind_text绑 UTF-8 字节。4.3 现象同一门课被同一个学生选了两次原因是没有加 UNIQUE 约束或者加了但插入时没检查返回码。SQLite 在违反 UNIQUE 时sqlite3_step返回SQLITE_CONSTRAINT但很多代码只判断SQLITE_DONE把约束冲突当成成功。解决建表时加UNIQUE(student_no, course_id)插入后判断rc ! SQLITE_DONE就回滚。这样即使界面层重复点击数据库也能兜住。4.4 现象教师关闭课程后学生还能选原因是在扣减余量的 SQL 里没加is_open 1条件或者界面刷新不及时。学生列表是打开时查的教师中途关闭课程学生那边还是旧数据。解决扣减 SQL 必须带AND is_open 1并且学生点选课时重新查一次课程状态。更彻底的做法是选课成功后发一个自定义消息刷新列表但单机场景下重新查询已经够用。4.5 现象程序退出时数据库文件被锁无法删除MFC 对话框程序如果没在OnDestroy或PostNcDestroy里调用sqlite3_close进程退出后文件句柄可能还被占用。尤其是调试运行时VS 可能持有文件。解决在CDbHelper::Close里调用sqlite3_close并在CSelectionApp::ExitInstance里调用。如果还是锁检查是否有未finalize的sqlite3_stmt每个prepare都要对应一个finalize。5. 进阶技巧用触发器自动维护余量一致性5.1 为什么还要触发器防止绕过应用层的直接改库即使应用层写了原子扣减如果有人直接用 SQLite 命令行改selection表course.remaining就会和实际选课人数不一致。触发器可以在数据库层面兜底。-- 插入选课记录时自动扣减余量 CREATE TRIGGER trg_selection_insert AFTER INSERT ON selection BEGIN UPDATE course SET remaining remaining - 1 WHERE id NEW.course_id AND remaining 0; END; -- 删除选课记录时自动回补余量 CREATE TRIGGER trg_selection_delete AFTER DELETE ON selection BEGIN UPDATE course SET remaining remaining 1 WHERE id OLD.course_id AND remaining capacity; END;有了触发器应用层的UPDATE course SET remaining remaining - 1就可以去掉只保留INSERT INTO selection。但注意触发器里的UPDATE如果因为remaining 0不满足而没执行INSERT仍然会成功导致选课记录存在但余量没扣。所以更稳妥的做法是应用层仍然做原子扣减触发器只作为数据修复手段不依赖它做业务控制。5.2 用事务嵌套保存点处理批量选课如果学生一次选多门课比如勾选了三门要么全成功要么全失败。SQLite 支持SAVEPOINT可以在一个大事务里对每门课设保存点。bool BatchSelect(const std::string studentNo, const std::vectorint courseIds, std::string errMsg) { sqlite3* db CDbHelper::Instance().GetDb(); sqlite3_exec(db, BEGIN IMMEDIATE;, nullptr, nullptr, nullptr); for (int cid : courseIds) { std::string sp SAVEPOINT sp_ std::to_string(cid) ;; sqlite3_exec(db, sp.c_str(), nullptr, nullptr, nullptr); std::string err; if (!SelectCourseInner(studentNo, cid, err)) { // 回滚到保存点继续尝试下一门还是全部回滚 // 业务上一般全部回滚 sqlite3_exec(db, ROLLBACK;, nullptr, nullptr, nullptr); errMsg 课程 std::to_string(cid) 选课失败 err; return false; } sqlite3_exec(db, RELEASE sp_ std::to_string(cid) ;, nullptr, nullptr, nullptr); } sqlite3_exec(db, COMMIT;, nullptr, nullptr, nullptr); return true; }SelectCourseInner是不带BEGIN/COMMIT的版本只做扣减和插入。外层用BEGIN IMMEDIATE包住内层用SAVEPOINT标记。任何一门失败就整体ROLLBACK保证批量选课的原子性。5.3 验证余量一致性的检查 SQL上线前跑一遍这个查询如果返回任何行说明余量和实际选课人数对不上。SELECT c.id, c.name, c.capacity, c.remaining, COUNT(s.id) AS actual FROM course c LEFT JOIN selection s ON s.course_id c.id GROUP BY c.id HAVING c.remaining ! (c.capacity - COUNT(s.id));正常情况应该返回 0 行。如果有行用下面的语句修复UPDATE course SET remaining capacity - ( SELECT COUNT(*) FROM selection WHERE selection.course_id course.id );我一般把这个检查放在教师界面的“数据校验”按钮里点一下就能看到有没有异常。做课程设计时这个功能很加分因为它说明你考虑到了数据一致性而不是只堆界面。5.4 最后说一个习惯我做完每个 MFC 数据库项目都会在退出前手动执行一次PRAGMA integrity_check;确认数据库文件没有损坏。选课系统虽然小但并发写加上强制退出SQLite 文件偶尔会出问题。这个习惯帮我省过好几次“后悔药”。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →