前段时间我动手写了一个C语言的银行系统练手项目,功能清单看着很常规:开户、销户、存钱、取钱、转账、查询、保存用户信息。但标题里真正藏关键的是最后那句——使用消息队列达到两个不同进程之间的相互通信。这句话直接把项目的难度从“文件读写练习”拉高到“进程间通信实战”。很多人拿到这类题目,第一反应是“几个控制台菜单而已”,真写起来才知道坑全在数据怎么持久化、两个进程怎么对话、并发时账目怎么不错乱。下面把我实现过程中的设计思路、关键函数和踩坑记录完整整理一遍,供正在做C语言课程项目或想练IPC的同学参考。
1. 从需求到架构:一个银行系统该怎么拆
1.1 功能清单其实就是模块划分图
拿到题目不要急着敲代码,先把“开户、存钱、取钱、转账、查询、销户、保存用户信息”这些词翻译成技术模块:
- 开户、销户:账户生命周期管理,涉及账号分配、状态维护。
- 存钱、取钱:单账户余额变更,属于最基础的账务操作。
- 转账:涉及两个账户的复合操作,核心是一致性。
- 查询:只读操作,但要避免读到写一半的数据。
- 保存用户信息:持久化层,决定程序重启后数据还在不在。
- 消息队列通信:把界面操作和底层数据服务拆到两个进程里。
把这些模块串起来,一个典型的C/S架构就出现了。客户端进程负责接收用户指令,服务端进程维护账户文件,两者通过消息队列交互。我当时把这个架子画出来之后,后续写代码的路径清晰很多。如果你把所有功能塞进单个进程,也能跑通,但消息队列这道题会逼着你把“界面层”与“数据层”分开,这恰恰是这个练习最值钱的部分。
1.2 为什么选消息队列而不是管道或共享内存
C语言里进程间通信的方式不少,有管道、信号、共享内存、消息队列、套接字等。题目点名用消息队列,我一开始也想过换成管道行不行,对比下来消息队列有三个明显优势。
管道是字节流,读写双方必须保持节奏一致,而且通常只支持单向流动,双向通信得开两条管道。共享内存虽然快,但同步互斥机制要自己用信号量维护,稍不注意就是数据竞争。消息队列是内核维护的一个消息链表,发送方把一条结构体消息丢进队列,接收方按类型取走,天然具有“数据块”语义和按类型路由的能力。
更直白地类比:管道是两个人拿一根管子对着喊,共享内存是几个人在一块白板上写字,消息队列则像一个带信箱编号的邮局。发信人把信丢进邮局,收信人按编号取信,中间不需要两边同时在线。这种模式非常适合“客户端发请求、服务端回响应”的命令问答场景。
1.3 项目文件结构先约定好
我建议一开始就规划目录,别写到后面像一锅粥:
bank/ ├── common.h # 公共结构体、消息定义、命令字常量 ├── server.c # 服务端进程,处理业务逻辑 ├── client.c # 客户端进程,交互菜单与消息收发 ├── account.dat # 用户信息数据文件 └── Makefile # 快速编译common.h 是通信协议的核心,两个进程都必须包含它。这样消息结构体只要改一处,两边同步生效。我把业务结构体叫BankMessage,把命令字叫CMD_OPEN、CMD_DEPOSIT这种名字,后面在消息队列章节会详细展开。
2. 用户信息持久化:账号文件的设计直接决定后续代码量
2.1 用定长结构体存账户,别碰文本解析
很多新手喜欢把用户信息写成逗号分隔的文本:
10001,张三,5000.00,1读取时用strtok拆字段,听起来简单,实际写起来很痛苦。字段长度一变,解析逻辑就得跟着改;数值中间出现空格或者中文名里夹带符号,直接乱套。我第二次重写时换成了定长二进制结构体:
#pragma pack(1) // 禁止字节对齐,保证文件记录长度固定 typedef struct { char account[16]; // 账号 char name[32]; // 用户名 long balance; // 余额,以“分”为单位 int status; // 1 正常,0 销户 } Account; #pragma pack()#pragma pack(1)在这里特别重要。如果不指定,编译器会按对齐规则在结构体字段之间插入填充字节,导致每条记录长度不确定。加了它,sizeof(Account)固定为 56 字节,文件里第 N 条记录的位置就是(N-1) * sizeof(Account),可以随时用fseek精准定位。
定位能力强了,查询账号就变成一次随机读取:
int find_account(FILE *fp, const char *account, Account *out) { // 先把文件头部的账户数量读出来,再逐个比较 // 也可以用哈希或索引,但简单项目里线性扫描足够 }这种定长方案虽然浪费一点磁盘空间,但换来了极高的编写效率和定位能力,非常适合练习项目。
2.2 写文件必须考虑“写一半崩溃”的问题
银行系统的数据文件承载的是账目,绝不能出现“写了一半断电”导致记录损坏的情况。我第一版直接用fseek + fwrite原地修改记录,测试时人为杀掉服务端进程,文件里的余额就会出现错乱。
后来改成“临时文件 + rename”策略:每次开户、存钱、转账需要修改数据时,不直接改原文件,而是先写一个临时文件,全部写完后用rename原子替换原文件。rename在同一个文件系统里是原子操作,要么新文件完全生效,要么原文件保持不变,不存在中间状态。
流程大致是:
1. 复制原文件到临时文件,或者打开原文件顺次读取; 2. 在临时文件里完成余额修改; 3. 刷新并关闭临时文件; 4. rename(临时文件名, 原文件名); 5. 删除临时文件(如果 rename 失败)。这种策略在很多真实系统里也常见,属于一种低成本的事务手段。
2.3 密码字段不能明文裸奔
题目只提到“保存用户信息”,但实际做出来免不了有密码校验。我第一版为了省事,把密码明文塞进结构体,后来自己测试时都觉得心虚——这个文件一旦被别人拿到,所有账户直接用记事本就能看穿。
简单项目不必上重量级加密,但至少不能明文。我建议用简单的哈希或者异或混淆:
unsigned char hash_pwd(const char *pwd, unsigned char *out) { unsigned char h = 0; while (*pwd) { h = (h << 5) + h + (unsigned char)(*pwd); // 类似简单哈希 pwd++; } return h; }如果只是交作业,加个哈希已经足够体现安全意识。真实生产环境自然要上更成熟的方案,这里不展开。
3. 消息队列:搞定两个进程的“双向传话”
3.1 消息队列的三个关键函数
消息队列的核心概念不复杂:内核维护一个队列,进程往队列里放消息,另一个进程从队列里取消息。使用前要理解三个函数:
msgget():创建或获取一个消息队列。msgsnd():向队列发送消息。msgrcv():从队列接收消息。
它们都依赖一个key,这个key一般用ftok()生成:
key_t key = ftok("/tmp/bank_queue", 66); int qid = msgget(key, IPC_CREAT | 0666);ftok两个参数是文件路径和一个整数项目ID。同一个路径和同一个ID生成的 key 是固定的,两个进程都调用同样的参数,就能拿到同一个消息队列标识。
各函数原型如下:
int msgget(key_t key, int flag); int msgsnd(int msqid, const void *msgp, size_t msgsz, int flag); ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long type, int flag);注意msgsnd的msgsz长度不包含long mtype字段,msgrcv的type参数是消息类型过滤条件,这个是后面所有通信协议设计的核心。
3.2 通信协议:给消息定一个双方都懂的格式
消息队列只负责传字节,它不关心字节里是什么。所以通信双方必须约定一套统一的“协议”,否则客户端发一堆字段,服务端根本不知道哪段是账号、哪段是金额。
我在common.h里定义:
#define MSG_TYPE_REQUEST 1 // 客户端 -> 服务端 的请求 #define MSG_TYPE_RESPONSE 2 // 服务端 -> 客户端 的响应 #define CMD_OPEN 1 #define CMD_CLOSE 2 #define CMD_DEPOSIT 3 #define CMD_WITHDRAW 4 #define CMD_TRANSFER 5 #define CMD_QUERY 6 typedef struct { long mtype; // 消息类型,用来区分请求和响应 int cmd; // 命令字 char account[16]; // 操作账号 char targetAcc[16]; // 转账目标账号 char name[32]; // 开户姓名 double amount; // 金额,单位为元(存储时转成“分”) int result; // 处理结果:0 成功,非0 错误码 int balanceAfter; // 查询余额时的返回 } BankMessage;这里最关键的一点:进程间通信时绝对不能传指针。每个进程有独立地址空间,你在客户端传一个指向栈上变量的指针,服务端拿到后访问的是一块毫无意义的地址。消息队列只允许传“值”。所有数据都要在结构体里按值拷贝。
我当时还顺手加了一个result字段,服务端处理完业务后把结果写回同一个结构体,再把mtype改成响应类型发回去。这就是最简单的“请求/响应”协议。
3.3 服务端接收循环:一条 while 打天下
服务端的核心是一个死循环,不停从队列里取请求,然后分发处理。伪代码框架如下:
while (1) { BankMessage msg; ssize_t size = msgrcv(qid, &msg, sizeof(msg) - sizeof(long), MSG_TYPE_REQUEST, 0); if (size < 0) { perror("msgrcv"); continue; } switch (msg.cmd) { case CMD_OPEN: result = open_account(&msg); break; case CMD_DEPOSIT: result = deposit(&msg); break; // 其他命令类似 default: msg.result = -1; break; } msg.mtype = MSG_TYPE_RESPONSE; msgsnd(qid, &msg, sizeof(msg) - sizeof(long), 0); }分段处理的好处是业务逻辑和通信逻辑分得很清楚。每一个业务函数都接收一个BankMessage*,处理完直接修改结构体里的result,返回给调用方发送。
3.4 多客户端场景下,光一个公共队列是不够的
题目只说两个进程,看起来像一个客户端一个服务端。但如果客户端能重复启动多个,就会出现一个问题:多个客户端都往同一个公共队列发请求,也都往同一个公共队列读“响应”,怎么保证张三收不到李四的响应?
最简单的方案是“每个客户端建一个自己的私有队列”。客户端把私有队列的qid随请求消息一起发给服务端,服务端处理完后调用msgsnd把响应发到那个私有队列。这样每个客户端只从自己的队列里读,消息不会串线。
我当时第一版没考虑这个问题,第二个终端一启动就出现“客户端A收到客户端B查询结果”的诡异现场。排查半天,意识到消息队列没有“定向投递”能力,它是靠消息类型和接收侧主动过滤来区分归属。用私有队列后,多客户端并行一下就正常了。
4. 核心业务功能的实现与并发控制
4.1 开户:先校验,再落盘
开户流程可以拆成几步:
- 检查姓名合法性,避免空名字。
- 生成唯一账号,一般用递增序号。
- 检查是否已有相同账号的存量数据。
- 用
fopen追加或者插入一条记录。 - 返回成功信息。
落盘时我用的是“读整个文件到内存,新增一条记录后整体重写”的策略。开户频率不高,性能不是主要矛盾。关键点在于生成账号那一步,不能简单用当前时间戳,因为同一秒开户两次可能撞号。用文件里已有的最大账号再 +1 比较稳妥:
int next_id = 0; while (fread(&acc, sizeof(Account), 1, fp) == 1) { if (acc.status != 1) continue; int cur = atoi(acc.account); if (cur > next_id) next_id = cur; } next_id++; snprintf(new_acc.account, sizeof(new_acc.account), "%d", next_id);4.2 存钱和取钱:读改写操作必须加锁
存钱取钱看起来就是读余额、加钱/扣钱、写回去,但这里有个经典的并发陷阱。假设余额是 100 元,进程A存50,进程B取30。如果两个进程同时读到“100”,A写入150,B写入70,最终结果是70,凭空少了30。
这不是理论问题,测试时用两个终端同时操作同一个账号,很容易复现。我的解决方案是引入“文件锁”,在每次读取余额前对文件记录范围加锁,写完后再解锁。C语言里可以用fcntl的F_SETLKW:
struct flock fl; fl.l_type = F_WRLCK; // 写锁 fl.l_whence = SEEK_SET; fl.l_start = account_offset; // 该账号记录在文件中的偏移 fl.l_len = sizeof(Account); // 只锁这一条记录 fcntl(fd, F_SETLKW, &fl); // 读取、修改、写入 fl.l_type = F_UNLCK; fcntl(fd, F_SETLKW, &fl);这里有个小细节:lockf或fcntl锁是基于文件描述符的,如果写操作之前做了fopen而不是open,要注意两个库函数混用可能会导致加锁失效。我后来统一用open+read/write配合lseek操作文件,避免stdio缓冲区带来的额外复杂度。
4.3 转账:两个账户的一致性最麻烦
转账比存钱取钱难在它要修改两条记录。如果先扣A账户,再加B账户,中间任何一个环节失败,账就平不了。
我的做法是给转账流程加一个简单的“事务编排”:
- 对A、B两条记录按账号大小排序,先锁账号较小的记录。
- 再锁账号较大的记录。
- 检查A账户余额是否足够。
- 执行“扣A、加B”。
- 同时写回文件,或者先写回A再写回B。
- 全部成功,解锁成功;任何一步失败,把已经写入的改动恢复,再解锁。
按账号排序加锁是为了避免两个客户端同时做反向转账时出现死锁。如果不排序,进程1先锁A再锁B,进程2先锁B再锁A,两边互相等对方释放锁,程序就卡死了。
具体业务函数原型:
int transfer(BankMessage *msg) { Account from, to; if (find_account(&from, msg->account) != 0 || find_account(&to, msg->targetAcc) != 0) { return ERR_ACCOUNT_NOT_FOUND; } // 按账号顺序加锁 if (strcmp(msg->account, msg->targetAcc) < 0) { lock_record(&from); lock_record(&to); } else { lock_record(&to); lock_record(&from); } if (from.balance < (long)(msg->amount * 100)) { unlock_record(&from); unlock_record(&to); return ERR_BALANCE_NOT_ENOUGH; } from.balance -= (long)(msg->amount * 100); to.balance += (long)(msg->amount * 100); write_record(&from); write_record(&to); unlock_record(&from); unlock_record(&to); return 0; }这里已经很接近真实系统里“事务 + 锁”的概念了。当然,真实银行系统不可能用文件锁做事务,但这个思路迁移过去是相通的。
4.4 查询余额与销户的细节
查询看起来最简单,但要注意读取模式。如果服务端还在写某条记录,客户端同时发查询请求,可能读到的余额是写了一半的脏数据。所以查询也应当加读锁:
fl.l_type = F_RDLCK; fcntl(fd, F_SETLKW, &fl); read_record(...); fl.l_type = F_UNLCK; fcntl(fd, F_SETLKW, &fl);销户则要决定一件事:账户里有余额怎么办?我实现的策略是余额必须为0才能销户,否则返回错误。这样避免“销户后钱不知去向”的历史问题。同时,我在结构体里用status = 0标记销户,而不是物理删除记录。保留原始数据的好处是后续如果要打印历史流水单,数据都还找得回来。
5. 这些坑我帮你踩过了:问题定位与修复速查
5.1 ftok 换了路径,队列就找不到了
ftok生成的 key 和传入的文件路径直接相关。如果路径叫/tmp/bank_q,程序启动时正常;后来把路径改成了/var/tmp/bank_q,两边进程就拿不到同一个队列了。还有一个坑是:路径必须真实存在,ftok并不会帮你创建文件。
所以建议把ftok的路径固定写成一个服务端启动时确保存在的文件路径,比如程序自己创建的配置目录,别用/tmp下随机临时文件名。
5.2 msgsnd 卡死:消息队列满了
消息队列有大小上限。当客户端发消息特别快,服务端处理不过来,队列塞满后msgsnd默认会阻塞住。我测试时用循环连发100条请求,程序直接卡在发送上。
解决办法是给msgsnd加IPC_NOWAIT标志,让它非阻塞返回错误,或者先查询队列当前字节数:
struct msqid_ds buf; msgctl(qid, IPC_STAT, &buf); // buf.msg_qbytes 是队列总字节上限 // buf.msg_cbytes 是当前字节数如果要用msgsnd的非阻塞模式:
if (msgsnd(qid, &msg, size, IPC_NOWAIT) == -1) { if (errno == EAGAIN) { // 队列满了,可以稍后重试或者提示用户 } }5.3 msgrcv 读走一条消息,别人就再读不到了
消息队列不是广播,一条消息只能被一个进程取走。如果两个客户端都往同一个队列里收响应,某一瞬间两个人可能抢到对方的响应。
很多初学者误以为“队列里的消息可以共享读”,实际上msgrcv是“取走”语义。要解决多客户端归属问题,回到前面说的私有队列方案,别让客户端共享响应队列。
5.4 不加文件锁,存进去的钱会“变少”
这个坑在4.2已经详细演示过,这里再强调一点:文件锁是“建议性锁”,不是“强制性锁”。也就是说,如果某个操作流程忘了加锁,别的进程同样能在没有锁的情况下读写文件,破坏数据一致性。所以每个修改入口都必须手动调用加锁函数,漏一个都不行。
我后来把所有涉及余额修改的逻辑收敛到三个函数里:deposit、withdraw、transfer。所有入口都走这三个函数,才彻底杜绝了漏锁问题。
5.5 double 算钱,账目差一分钱
这是老生常谈。计算机里浮点数不能精确表示所有小数,比如0.1 + 0.2的结果是0.30000000000000004。如果余额用double存,频繁加减后误差会累积,对账的时候差几分钱是最难受的。
我建议内部存储一律用“分”这个整数单位。用户输入100.50元,程序立刻转成10050分来处理。只有显示给用户时再除以100。结构体里long balance存的就是分,不要再存double。
5.6 服务端重启后处理了一堆“旧消息”
消息队列是内核对象,服务端进程退出后队列还在。如果之前客户端发了一堆没处理完的请求,服务端重启后又会把这些旧请求当新请求处理,结果可能是把“昨天的存钱”又执行了一遍。
解决方案是在服务端启动时主动清空队列:
msgctl(qid, IPC_RMID, NULL); // 然后重新 msgget 创建或者更优雅一点,在每条消息里增加自增序号或时间戳,服务端识别出过期请求直接丢弃。练习项目中直接清空队列最简单。
5.7 用 ipcs 排查队列状态
排查消息队列问题时,别靠猜,直接看内核状态。Linux 下用ipcs -q能显示队列标识、消息数、总字节数。某个消息积压在队列里不消失,直接看msgsnd和msgrcv的调用参数是否匹配。
不需要用的队列可以用ipcrm -q 队列编号手动删除。但如果程序还在跑,删掉后需要重新创建,程序内部的qid也要重新获取,所以一般只在调试时手动清理。
下面整理成速查表:
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 客户端发消息,服务端收不到 | ftok 路径不一致或 key 不匹配 | 统一 ftok 参数 |
| msgsnd 卡住 | 队列满 | 使用 IPC_NOWAIT 或加快服务端消费 |
| 多个客户端收到同一个响应 | 共享响应队列 | 客户端使用私有队列 |
| 余额偶尔少钱 | 未加锁并发写 | 用 fcntl 加记录锁 |
| 金额总和差几分 | 用 double 存钱 | 改成整数“分”存储 |
| 服务端重启后处理旧请求 | 队列残留旧消息 | 启动时 IPC_RMID 清空 |
6. 从作业到工程:还能往哪些方向扩展
6.1 把账号文件换成数据库
文件存储虽然简单,但在并发、检索、事务上都很吃力。真实系统至少会用 SQLite 这类嵌入式数据库,或者直接上 MySQL/PG。如果学有余力,可以保留消息队列通信框架,把服务端的文件读写替换成 SQL 跟数据库交互。你会发现消息队列的壳完全不用动,业务函数内部改改就能跑。
6.2 用多线程替代多进程
题目限定“两个进程”,但生产环境里通常是一个服务端进程内开多条工作线程,客户端通过消息队列把请求丢给线程池处理。这里面还要考虑线程安全:文件锁、内存数据结构加锁、消息队列并发读写。多线程比多进程更能直观暴露共享变量的竞争问题,值得单独做一个版本对比。
6.3 增加日志、对账与权限控制
真实银行系统要做三件事:操作日志、日终对账、权限控制。操作日志就是给每一笔存取款、转账记录一条不可篡改的日志;对账是定期把日志流水和余额文件重新计算一遍,确保账实相符;权限控制则是区分管理员、普通用户、只读查询者。
即使在练习项目里加一个小型的 append-only 日志文件,代码复杂度也不会增加太多,但整个项目的完整度和说服力会高一个档次。
6.4 通信层升级为网络通信
消息队列只能在同一台主机上的进程之间通信。如果把客户端换成网络版,服务端需要监听某个端口,那就超出进程间通信范畴,进入网络编程领域了。这时候可以用 Socket + 自定义协议替换消息队列,或者在消息队列前面加一层网络网关。想往服务器方向走的话,这个扩展路径非常值得走一遍。
这个项目我做完最大的一个体会是:别小看这些基础功能。存钱取钱背后是读改写原子性,转账背后是事务一致性,消息队列背后是协议设计和路由边界。每一块单独拆出来都不难,但组合到一起,就是一个小型业务系统的缩影。如果你也在做类似练习,我建议第一步先把存储格式和消息协议定下来,再写业务函数,顺序反了会改到怀疑人生。
最后再分享一个小技巧:调试这种两个进程协作的程序,多开几个终端,一边跑服务端一边跑客户端,用ipcs -q盯着队列状态。看到消息数、字节数变化时的直观感受,比你读十遍手册都管用。做完这个项目,再回去看任何“C/S 架构”“消息中间件”的资料,你会觉得很多东西似曾相识。