资讯动态

Qt与C++构建银行系统:epoll事件驱动与MySQL事务实践

发布时间:2026/9/16 8:36:21 来源:尧图企业网站定制
简介基于Qt与C实现的银行管理系统完整源码面向需要完成毕业设计或学习C/S架构开发的计算机专业学生。系统在Linux环境下开发服务器端采用epoll模型配合MySQL数据库存储代码中应用了单例模式、抽象工厂及对象动态创建等设计模式具备开户、存款、取款、转账、余额查询、历史记录查询等完整业务功能。压缩包共121个文件包含45个头文件、41个C源文件、11个UI界面文件以及工程配置和Makefile整体仅178KB结构清晰便于阅读和学习。已有616人学习下载适合作为课程设计或毕业设计的参考范本。资源不仅提供了可直接运行的BankServer程序还附带了各功能模块的分离源码帮助理解服务器网络编程、数据库交互与界面设计的整合思路。1. 用 Qt 和 C 拆银行系统先分清 C/S 两端在干什么不少课设里的银行管理系统界面和业务都堆在同一个进程里数据库连接每次操作都重新建服务端一开线程就再不管锁。这套代码不是那种写法它在 Linux 下用 epoll 做服务端事件驱动MySQL 存账户和流水Qt 这边只有 LoginDialog、OpenDialog 这些界面层网络分包、密码散列、业务逻辑分别独立。对准备答辩的人来说最值得学的不是 Qt 控件有多漂亮而是 C/S 形态下每个文件该承担什么角色。下面按拆机方式过一遍网络层、存储层、客户端流程、业务事务最后讲构建和调试里容易绊倒人的地方。2. network.cpp 的 epoll 模型把 socket 事件和业务线程分开2.1 为什么先选 epoll 而不是一连接一线程银行系统并不会像门户网站那样跑几十万并发但 C/S 结构下仍然要面对“多个客户端同时开户、转账”的窗口期。如果服务端用阻塞式 accept每来一个连接就new thread数据库一条慢 SQL 就可能让几十个线程阻塞在那里用 select 也有 1024 个 fd 的上限每次还要遍历全部 socket。epoll 解决的问题是让单个线程知道哪些 fd 可读、可写但它只负责通知真正的数据库操作必须离开事件循环否则一条查询就会拖住后面所有连接。这个项目里network.cpp的核心就是三层结构socket 事件采集、二进制分包解析、业务线程池分发。事件循环里只做epoll_wait和read拿到一个完整请求包后把它提交到线程池执行。这样即便某个账户的转账事务跑了几十毫秒其他客户端的登录请求也能先被读取不会被一个慢事务堵死。这里需要明确一个边界epoll 不是线程池替代品。统计连接数用 epoll计算和数据库操作用固定线程池两者配合才有意义。如果只开一个线程做 epoll 又直接在该线程里执行 SQL那和单线程阻塞 server 没有本质区别。network.cpp里如果能找到ThreadPool或任务队列就是理解了这一步。2.2 事件循环里的读写边界和超时参数下面这段是整理后的网络主循环我在写类似服务端时习惯保持这个形状// network.cpp 核心事件循环 int epfd ::epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; // 水平触发初版不容易漏事件 ev.data.fd listen_fd; ::epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); std::vectorepoll_event ready(1024); while (isRunning()) { int n ::epoll_wait(epfd, ready.data(), ready.size(), 50); for (int i 0; i n; i) { if (ready[i].data.fd listen_fd) { handleAccept(epfd, listen_fd); continue; } Request req handleReadFrom(ready[i].data.fd); if (!req.isNull()) { thread_pool.submit([this, req]() { dispatchBusiness(req); }); } } }这里epoll_wait的第四个参数是 50ms 超时不是越小越好。设成 -1 会一直阻塞程序退出信号没法及时响应设成 0 会变成忙轮询CPU 占用高。50ms 作为心跳间隔能兼顾连接超时检查和 epoll 事件响应。ready数组 1024 只是预分配大小返回的n小于等于数组大小如果同时就绪超过 1024剩下的事件会在下一轮epoll_wait返回不会丢。handleReadFrom要自己维护每个 fd 的接收缓冲区。常见的错是把read出来的字节直接当完整包处理但 TCP 是流协议一个write可能被拆成两次到达也可能两个包粘在一次到达。所以缓冲区必须累积数据直到能拼出一个完整报文。每次read之后都要尝试解析解析出一个包就返回一个Request缓冲区里剩下的字节留到下次继续解析。2.3 自定义二进制协议长度、操作码和 body项目中服务端和客户端约定了一个最简单的报文头前 4 字节是 body 长度按网络字节序大端存放接着 2 字节是操作码后面是 body。body 里用竖线分割字段比如开户请求是账号|密码。这样拆包逻辑可以复用不用为每个业务写不同的解析分支。// 粘包/半包处理成功解出一个包返回 true同时移除已消费字节 bool tryDecodePacket(QByteArray buf, Packet p) { while (buf.size() 6) { int len ((unsigned char)buf[0] 24) | ((unsigned char)buf[1] 16) | ((unsigned char)buf[2] 8) | (unsigned char)buf[3]; if (buf.size() len 6) return false; // 半包继续等 p.op ((unsigned char)buf[4] 8) | (unsigned char)buf[5]; p.body buf.mid(6, len); buf.remove(0, len 6); return true; } return false; }说明一下while而不是if的原因缓冲区里可能同时有多个请求包比如客户端点击转账后连发几条指令一次readyRead就可能带来两包数据。只解一个包会留下半个包等下一次事件又可能把后续顺序搞乱。每次解包后返回 true循环继续直到缓冲区不够一个完整头才停下来等新数据。操作码目前定义如下客户端和服务端必须同一张表。操作码含义对应文件/模块0x1001登录LoginDialog0x1002开户OpenDialog / Open.cpp0x2001存款服务端交易处理0x2002取款Withdraw.cpp0x2003转账Transfer.cpp0x2005历史记录History.cpp协议解析这里要提一个容易忽略的点不要用strlen或者QString::fromUtf8收到\0就截断因为 body 可能包含二进制内容。长度字段必须严格按字节处理否则转账金额或 md5 值会把后续包切错。调试时如果发现服务端和客户端显示的报文长度不一致优先检查长度字段转成整型时是不是把有符号char做了符号扩展。3. My_Sql.cpp 与 MD5.cpp单例连接和密码散列的落地写法3.1 建表与字段DECIMAL 和索引是底线数据库表结构决定了事务代码能不能简洁。账户表要能直接支撑余额更新和登录校验流水表要能支撑历史记录分页。下面是整理后的建表语句CREATE TABLE IF NOT EXISTS account ( account_no VARCHAR(32) PRIMARY KEY, salt CHAR(16) NOT NULL, pass_md5 CHAR(32) NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE IF NOT EXISTS account_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(32) NOT NULL, op_type VARCHAR(16) NOT NULL, amount DECIMAL(18,2) NOT NULL, balance_after DECIMAL(18,2) NOT NULL, counterparty VARCHAR(32) NULL, created_at DATETIME NOT NULL, INDEX idx_account_time (account_no, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段的取舍可以直接写进答辩材料balance用DECIMAL(18,2)而不是FLOAT/DOUBLE因为浮点数在二进制里不能精确表示 0.1累计到第十次取款就可能出现 0.999999DECIMAL(18,2)是定点数正好满足两位小数的金额精度。account_no作为业务主键是因为登录和转账都直接用卡号定位账户省去一次索引回表但字符串主键在 InnoDB 里是聚簇索引写入时可能产生页分裂课设数据量下不用担心。account_log的idx_account_time是联合索引顺序是account_no, created_at这样查询某个账号历史记录时能用上索引。不要把account_no和created_at分开建两个普通索引联合索引已经能覆盖这个查询场景后续按操作类型过滤可以再扩展。字段类型设计要点account.balanceDECIMAL(18,2)避免浮点误差account.saltCHAR(16)开户随机生成和MD5摘要一起存account_log.op_typeVARCHAR(16)不建枚举方便扩展account_log.idBIGINT AUTO_INCREMENT历史记录天然有序便于游标分页3.2 单例模式管理 MySQL 连接服务端My_Sql.cpp里的数据库连接用一个单例持有。我之前在别的 Qt 项目里也这么写过关键是把初始化和获取连接分开class MySql { public: static MySql instance() { static MySql inst; // C11 之后局部静态变量初始化线程安全 return inst; } bool init(const char* host, unsigned port, const char* user, const char* pass, const char* db) { conn_ mysql_init(nullptr); return conn_ mysql_real_connect(conn_, host, user, pass, db, port, nullptr, 0); } MYSQL* get() { return conn_; } private: MySql() default; ~MySql() { if (conn_) mysql_close(conn_); } MySql(const MySql) delete; MySql operator(const MySql) delete; MYSQL* conn_ nullptr; };这段代码把构造函数私有化保证外部不能创建第二个连接。static MySql inst是 Meyers 单例在 C11 标准下是线程安全的第一次调用instance()时其他线程会等初始化完成之后调用get()拿到同一个MYSQL*。但要注意一个边界单例保证只有一份连接不代表业务线程可以并发操作这个连接。MySQL C API 的mysql_real_query同一连接同时只能跑一条 SQL两个线程交叉执行会造成结果错乱。项目里常见做法是给所有 SQL 操作加一个互斥锁。如果你要优化把单例连接换成连接池每个线程拿独立连接再配合事务锁吞吐量会好很多课设里单例加锁足够答辩时能讲清楚两者取舍就很好。3.3 密码不是明文加盐 MD5 的写法与边界MD5.cpp在这个系统里主要做两件事开户时生成密码摘要登录时比对接到的摘要。只调用一次 MD5 在现在任何一个真实业务里都不够看所以项目里应该在密码前拼一个随机 salt。开户时生成盐再入库登录时先按账号查 salt再算摘要std::string encodePassword(const std::string salt, const std::string password) { std::string hash md5(salt password); // 简单迭代提高暴力破解成本 for (int i 0; i 128; i) { hash md5(hash salt); } return hash; }盐存放在account.salt字段长度 16 字符来源可以是QDateTime::currentMSecsSinceEpoch() 随机字符。这样即使用户密码是 123456不同账号的摘要也不同两张用户表摆在一起也看不出谁密码相同。需要说明的是MD5 是摘要算法而不是加密算法速度太快GPU 每秒能算几十亿次所以正式生产系统应该换成 PBKDF2、bcrypt 或 Argon2。但毕业设计评委问“为什么用 MD5”时能说清楚“加盐 迭代 数据库同步校验”已经比裸 MD5 好很多。另一个容易被忽略的点是 MySQL 8 默认认证插件caching_sha2_password老版本 libmysqlclient 连不上时服务端连接报Authentication plugin caching_sha2_password cannot be loaded这不是代码逻辑问题需要在 MySQL 侧把用户切到mysql_native_password或者在 Qt 端换用新版驱动。4. LoginDialog 与 OpenDialogQt 客户端如何把信号槽用对4.1 用 QTcpSocket 做异步连接不阻塞 UI客户端这边的LoginDialog拿到用户输入的账号密码后不是直接waitForConnected()而是用QTcpSocket的异步信号。银行登录按钮点了之后界面可能会等几百毫秒如果直接阻塞在这里窗口会变成“未响应”Mac 和 Windows 下甚至会被系统强制标记卡死。正确做法是在构造函数里把 socket 的事件信号连给对话框自己的槽点击登录时只负责connectToHost或直接发送数据。整理后的代码结构是这样// LoginDialog.cpp LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent), socket_(new QTcpSocket(this)) { connect(socket_, QTcpSocket::readyRead, this, LoginDialog::onReadyRead); } void LoginDialog::onLoginClicked() { if (socket_-state() ! QAbstractSocket::ConnectedState) { socket_-connectToHost(serverIp_, serverPort_); pendingLogin_ true; // 连接建立后再发登录包 return; } sendLoginPacket(); } void LoginDialog::onReadyRead() { QByteArray incoming socket_-readAll(); buffer_ incoming; Packet p; while (tryDecodePacket(buffer_, p)) { if (p.op 0x1001) { handleLoginResponse(p.body); } } }注意pendingLogin_这个标志位。connectToHost 是异步的connected信号还没有到来如果在这里就write登录包数据会先进入发送缓冲等待连接建立逻辑上也行得通但如果你之后想发多条指令最好在connected信号里用同一个入口发避免用户重复点击按钮导致两个登录包叠在一起。按钮槽函数不要把loginResult_作为返回值返回Qt 信号槽机制是异步派发槽的返回类型是 void状态只能通过成员变量保存之后再转发给主窗口。4.2 响应包解析要小心处理半包和错误码客户端的onReadyRead和第二章的服务端共用同一个tryDecodePacket但要注意readAll()拿到的数据同样可能是一个包的一半。所以客户端也要维护一个buffer_成员变量每次readyRead先append再循环解析不能每次都把 buffer 清空。下面这段是登录响应的解析void LoginDialog::handleLoginResponse(const QByteArray body) { QListQByteArray parts body.split(|); if (parts.size() 3 parts[0] ok) { account_ QString::fromUtf8(parts[1]); serverTime_ QString::fromUtf8(parts[2]); accept(); } else { QMessageBox::warning(this, 登录失败, QString::fromUtf8(parts.value(1))); } }这里 body 里返回了三段状态、账号、服务器时间。把服务器时间也带回来是方便客户端和服务端日志对时排查问题时能看清一条请求到底在哪个环节耗时。注意parts.value(1)在数组越界时返回空 QByteArray不会崩溃但如果 body 格式被服务端写得不对用户只能看到空提示服务端日志里要把原始 body 打出来否则这类问题很难发现。响应包的op字段必须和服务端实现对应。如果服务端处理完业务后回同一个 op客户端就按0x1001处理如果所有响应都用0x0000固定码客户端会越写越乱。这里建议每类请求固定一个请求/响应对登录用0x1001开户用0x1002后续业务同理。不要为了省事把所有响应都混成一个 op。4.3 开户对话框前端校验只是辅助服务端必须重查OpenDialog的写法比登录多两层前端要做二次密码校验服务端要做唯一性校验。前端校验能够在用户输错时立刻提示但永远不应该作为安全边界。原因很简单抓包工具可以直接向服务端发送一条开户指令绕过对话框。所以Open.cpp在插入账户前必须自己检查账号格式和密码非空。// OpenDialog.cpp 按钮槽 void OpenDialog::onOpenClicked() { QString account ui-accountEdit-text(); QString pass ui-passEdit-text(); QString pass2 ui-pass2Edit-text(); if (pass ! pass2) { QMessageBox::warning(this, 开户失败, 两次输入的密码不一致); return; } if (account.size() ! 16 || !account[0].isDigit()) { QMessageBox::warning(this, 开户失败, 账号必须是16位数字); return; } sendPacket(0x1002, account.toUtf8() | pass.toUtf8()); }服务端收到后要处理并发开户同一账号的情况。简单做法是先SELECT判断再INSERT但两个客户端同时点开户时两边都看到“不存在”然后都执行插入后提交的那个会撞上主键约束。所以Open.cpp里应该依赖数据库唯一索引兜底INSERT时捕获ER_DUP_ENTRY再把“账号已存在”翻译成业务错误码返回。这样即使用户反复点击也不会出现重复数据。5. Transfer、Withdraw 与 History 的事务串起来5.1 取款和存款条件 UPDATE 比先查后改安全取款这类余额变更最忌讳的写法是“先 SELECT 取出 balance程序判断够不够再 UPDATE 回写”。两个客户端同时对同一个账号取款都读到余额 100都判断够各自更新成错误值最终结果就不是预期值。正确写法是把余额判断放进 UPDATE 语句让数据库自己在行锁内判断// Withdraw.cpp 事务片段 mysql_autocommit(db.get(), 0); QString sql QString( UPDATE account SET balance balance - %1 WHERE account_no %2 AND balance %1) .arg(QString::number(amount, f, 2)) .arg(accountNo); if (mysql_real_query(db.get(), sql.toUtf8().constData(), sql.toUtf8().size()) ! 0) { mysql_rollback(db.get()); mysql_autocommit(db.get(), 1); return false; } if (mysql_affected_rows(db.get()) 0) { // 没有行被更新说明账号不存在或余额不足 mysql_rollback(db.get()); mysql_autocommit(db.get(), 1); setError(余额不足或账号不存在); return false; } // 插入 account_log 流水... mysql_commit(db.get()); mysql_autocommit(db.get(), 1);这段 SQL 里没有把金额写死成变量是为了让你看清条件balance %1放在WHERE里数据库更新时先对目标行加独占锁再判断余额条件不满足则影响行数为 0。mysql_affected_rows返回 0 时一定要回滚不能继续插流水。mysql_autocommit在执行前后要恢复很多同学忘记在函数退出前把 autocommit 设回 1后续 SQL 会一直处于事务中提交时机变得不可控。这里还有一个细节amount 用QString::number(amount, f, 2)转字符串不要直接用QString::number(amount)否则 0.10.2 的结果可能变成 0.30000000000000004。正式场景应该用QSqlQuery的预处理语句绑定参数绑定之后再传给服务端。5.2 转账事务按账户号排序规避死锁转账是系统的复杂点。一次转账至少包含四个动作扣转出账户、增加转入账户、写两条流水记录。这些动作必须在一个事务里执行否则转账中途宕机钱会凭空消失。逻辑顺序如下检查转出账户状态和余额。UPDATE account SET balance balance - ? WHERE account_no ? AND balance ?。UPDATE account SET balance balance ? WHERE account_no ?。插入两条流水counterparty字段记录对方账号。提交事务任一步失败则回滚。死锁发生在两个转账请求方向相反时事务 A 锁了账户 1 再锁账户 2事务 B 锁了账户 2 再锁账户 1两者互相等待。避免死锁的常规做法是让所有事务按同一个顺序加锁。Transfer.cpp里可以先比较两个账号的字典序小的在前大的在后无论在业务参数里谁是转入方执行的 SQL 都是先锁小账号再锁大账号。这样两个互转请求不会出现交叉等待。5.3 历史记录查询走索引、用游标分页History.cpp负责查account_log。查询条件通常是账号和时间范围页面上显示最近 30 条用户滚动后再加载下一页。SQL 写成这样SELECT id, account_no, op_type, amount, balance_after, counterparty, created_at FROM account_log WHERE account_no ? ORDER BY id DESC LIMIT 30 OFFSET ?;注意ORDER BY id DESC而不是ORDER BY created_at DESC。id是自增主键天然代表写入顺序用主键排序能直接走聚簇索引不用额外排一次序。created_at上虽然有索引但时间精度到秒同一秒内多条记录顺序不确定分页时可能出现重复。如果用户要按时间范围过滤就把条件改成created_at ? AND created_at ?不要写DATE(created_at) ?这种把字段包进函数的写法否则索引会失效变成全表扫描。分页 OFFSET 在深翻页时性能会变差LIMIT 300000, 30仍然要扫描 300030 行再丢前 300000 行。交易流水更适合用游标分页上一页返回的结果里带最后一个id下一页查询条件加WHERE id 上页最小id只取 30 条。这样无论翻到第几页查询范围都被控制在一个主键区间内。问题现象处理余额竞态两笔取款同时通过条件 UPDATE affected_rows 校验重复开户插入主键冲突捕获 ER_DUP_ENTRY 映射为业务错误历史查询慢深翻页扫描大量行游标分页或WHERE id ?6. 构建、调试和验证这套 C/S 系统的关键点6.1 .pro 依赖和运行库服务端BankServer.pro至少需要network、core模块MySQL 连接直接链接 mysqlclientTEMPLATE app CONFIG console c11 QT core network LIBS -lmysqlclient -lpthread SOURCES BankServer.cpp network.cpp My_Sql.cpp MD5.cpp Transfer.cpp如果开发机是 Ubuntu 20.04缺少 mysql 头文件时先装libmysqlclient-dev否则#include mysql.h会报找不到文件。Windows 下用 Qt 5.15 msvc2019_64 编译时程序启动崩溃先检查是不是没装对应版本的 Visual C Redistributable事件查看器里能看到0xc000007b这类错误十有八九是运行库位数不对。6.2 几个高性价比的调试手法先在服务端把每个包的 op、长度和 body 用qDebug()打印成十六进制对比客户端发送记录能快速定位分包错位。数据库事务卡住时在 MySQL 里执行SHOW ENGINE INNODB STATUS\G看LATEST DETECTED DEADLOCK段落里面会写出死锁涉及的表和行。Qt 端崩溃若发生在onReadyRead里多半是跨线程修改了界面控件把更新 UI 的代码放进信号槽而不是直接调用。6.3 并发验证一个真实场景验证系统是否可靠开两个客户端对同一个账号同时做 50 次取款 1 元初始余额 100 元正确结果应是失败 50 次、余额 50 元。如果出现负余额说明balance ?条件写回了错误的位置。跑完后再用mysql查询account_log流水条数应该等于成功次数不应该出现只扣款不记流水的情况。epoll_wait 的 timeout 保持 50ms同时把 MySQL 的innodb_lock_wait_timeout调成 5 秒观察日志时间戳就能看出两条并发请求在哪里互相等待锁。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价