摘要:本文是 C++ 预约系统实战系列的第一篇,从零拆解一个基于 TCP Socket + JSON 协议 + MySQL 的 C/S 架构项目。文章涵盖业务场景、整体架构、目录结构、核心类职责,并以“用户预约”为例完整走通一次请求的来龙去脉,最后给出技术选型理由和面试高频考点。
本文涉及的以下知识点,在之前的文章中已详细讲解:
-
Tcp/Socket 编程基础Linux TCP 网络编程完全指南:从三次握手到高并发服务器-CSDN博客
-
IO 多路复用(select / poll / epoll )
-
Libevent 事件驱动 → Libevent实战:高性能网络编程指南-CSDN博客
-
MySQL 基础(CRUD、事务、索引)→ MySQL 核心特性深挖:ACID、隔离级别、索引与视图的底层原理-CSDN博客
-
JSON 数据格式 → JSON 完全指南:从语法到项目实战-CSDN博客
一、这个项目要做什么
一句话:一个跑在终端里的预约系统。
用户可以注册、登录、查看可预约的资源(比如场馆、票务)、下单预约、查看自己的预约记录、取消预约。服务端负责业务逻辑和数据库读写,客户端只负责界面交互和网络收发。
功能清单如下:
| 注册 | User_Resgister() | User_Resgister() | INSERT user_info |
| 登录 | User_Login() | User_Login() | SELECT user_info |
| 查看可预约 | User_Show_Ticket() | User_Show_Ticket() | SELECT ticket_info |
| 预约 | User_Subscribe_Ticket() | User_Subscribe_Ticket() | 事务:UPDATE + INSERT |
| 查看我的预约 | User_Show_Sub_Ticket() | User_Show_Sub_Ticket() | JOIN 联表查询 |
| 取消预约 | User_Cancel_Sub_Ticket() | User_Cancel_Sub_Ticket() | 事务:UPDATE + DELETE |
注意:这是一个学习项目,不会真正上线。它的价值在于:把「Socket 编程 + 协议设计 + 数据库操作 + 事件驱动」串成一条完整的工程链路,而不是停留在单点知识。
二、整体架构图

三层职责非常清晰:
-
客户端:只管“界面 + 收发”,不做任何业务判断(除了输入合法性)。
-
服务端:业务逻辑 + 协议解析 + 数据库操作,是真正的大脑。
-
数据库:持久化,用外键约束兜底数据一致性。
三、目录结构与类职责

客户端:socket_client
class socket_client
{
private:
string ips; // 服务器 IP,默认 127.0.0.1
short port; // 服务器端口,默认 6000
int sockfd; // 通信套接字
bool dl_flg; // 是否已登录
string username; // 当前用户名
string usertel; // 当前用户手机号(后续请求要带上)
int user_op; // 用户当前选择的菜单项
bool runing; // 主循环开关
Json::Value m_val; // 复用的 JSON 对象
};
关键设计:
-
dl_flg 区分登录态:未登录时菜单只有 1/2/3,登录后变成 1/2/3/4/5。
-
usertel 是后续所有请求的“身份证”:预约、查看我的预约、取消预约都要带上它。
-
user_op 加 OFFSET:菜单输入 1~5,加上 OFFSET=2 后映射到枚举 CKYY(3)~QXYD(7),这样就能直接复用 switch。
const int OFFSET = 2;
enum OP_TYPE { DL=1, ZC, CKYY, YD, CKYD, QXYD, TC };
为什么要偏移?因为 DL=1, ZC=2 是未登录菜单,登录后菜单第 1 项应该对应 CKYY=3,所以 1 + OFFSET = 3。这个设计很隐晦,但省了一套枚举。
服务端:三个类
| socket_listen | 创建监听套接字、bind、listen,持有 event_base |
| socket_con | 一个连接一个对象,负责收数据、解析 JSON、路由到业务函数、发响应 |
| mysql_client | 封装所有 SQL 操作,含事务 begin/commit/rollback |
重点:socket_con 是每个连接 new 出来的,挂在 libevent 上。
socket_con *q = new socket_con(c);
struct event *c_ev = event_new(p->Get_base(), c, EV_READ | EV_PERSIST, SOCK_CON_CALLBACK, q);
q->Set_ev(c_ev);
event_add(c_ev, NULL);
当连接可读时,libevent 回调 SOCK_CON_CALLBACK → q->Recv_data()。处理完一个请求后,事件仍然挂着(EV_PERSIST),等下一个请求。
这已经是标准的 Reactor 模式,不是单线程阻塞。这一点在你之前发的概述文档里写错了,面试时要说清楚。
四、一次“预约”请求的完整生命周期
这是本文最核心的部分。以「用户预约」为例,从按下键盘到数据库落盘,完整走一遍。
时序图

关键代码逐段拆解
第 4 步:客户端构造 JSON 并发送
void socket_client::User_Subscribe_Ticket()
{
User_Show_Ticket(); // 先展示可预约列表
cout << "请输入要预定的编号:" << endl;
int index = 0;
cin >> index;
Json::Value val;
val["type"] = YD; // 操作类型:预约
val["tel"] = usertel; // 谁在预约
val["index"] = index; // 预约哪个
send(sockfd, val.toStyledString().c_str(),
strlen(val.toStyledString().c_str()), 0);
char buff[256] = {0};
int n = recv(sockfd, buff, 255, 0);
// … 解析响应
}
注意 toStyledString() 会生成带缩进和换行的 JSON,可读性好但浪费带宽。生产环境一般用 toFastString() 或直接 write 到 StreamWriterBuilder。
第 6 步:服务端解析并路由
void socket_con::Recv_data()
{
char buff[256] = {0};
int n = recv(c, buff, 255, 0);
if (n <= 0) { delete this; return; }
Json::Reader Read;
if (!Read.parse(buff, val)) { Send_err(); return; }
int ops = val["type"].asInt();
switch (ops)
{
case DL: User_Login(); break;
case ZC: User_Resgister(); break;
case CKYY: User_Show_Ticket(); break;
case YD: User_Subscribe_Ticket(); break;
case CKYD: User_Show_Sub_Ticket(); break;
case QXYD: User_Cancel_Sub_Ticket(); break;
default: break;
}
}
这就是一个极简的协议路由:type 字段是路由键,switch 是路由表。
第 9~13 步:数据库事务
bool mysql_client::mysql_Subscribe_Ticket(int tk_id, string tel)
{
mysql_user_begin(); // begin
string s1 = "select max,num from ticket_info where tk_id=" + to_string(tk_id);
mysql_query(&mysql_con, s1.c_str());
MYSQL_RES *r = mysql_store_result(&mysql_con);
MYSQL_ROW row = mysql_fetch_row(r);
int tk_max = atoi(row[0]);
int tk_num = atoi(row[1]);
if (tk_max <= tk_num) // 判断是否满员
{
mysql_user_rollback();
return false;
}
tk_num++;
string s2 = "update ticket_info set num=" + to_string(tk_num)
+ " where tk_id=" + to_string(tk_id);
mysql_query(&mysql_con, s2.c_str()); // 更新已预约数
string s3 = "insert into sub_ticket values(0,"
+ to_string(tk_id) + ",'" + tel + "',now())";
mysql_query(&mysql_con, s3.c_str()); // 插入预约记录
mysql_user_commit(); // commit
return true;
}
这段代码有几个面试必问的坑,我逐一标出来:
select 没加 for update → 并发下两个线程可能都查到 num=3,都判断通过,都执行 update,最终 num 变成 4 但实际插入了 2 条记录,超卖。
SQL 字符串拼接 → tel 如果传 ' or '1'='1,就能注入。
update 用绝对赋值 num=4 而不是 num=num+1 → 也是并发问题的根源。
每个请求新建一个 MySQL 连接 → 高并发下连接数爆炸,应该用连接池。
这些不是“错误”,而是学习项目的简化。面试时主动说出来,比被问倒强。
五、技术选型理由
| C++ | 贴近底层,能展示 Socket、内存管理、指针操作 | “想真正理解网络编程和内存模型,而不是调库” |
| TCP | 可靠传输,面向连接,适合请求-响应模式 | “预约不能丢请求,TCP 的重传和有序保证更合适” |
| JSON | 可读性好,jsoncpp 库成熟,开发快 | “小规模系统 JSON 足够;protobuf 适合高性能、强 schema 场景” |
| libevent | 跨平台事件驱动,封装了 epoll/select | “比裸 epoll 开发效率高,又比多线程模型更省资源” |
| MySQL C API | 直接写 SQL,理解底层 | “不想被 ORM 屏蔽细节,想自己控制 SQL 和事务” |
一个关键点:为什么服务端用 libevent 而不是多线程?
-
多线程模型:一个连接一个线程,代码直观,但线程切换开销大,连接数一多就崩。
-
libevent 模型:单线程事件循环,一个线程处理所有连接,资源占用低,适合 IO 密集型。
-
本项目的业务逻辑很短(查库、写库),瓶颈在 IO 不在 CPU,所以事件驱动更合适。
六、面试高频题
Q1:为什么用 C++ 而不是 Java/Python 做这个项目?
答:主要目的是学习。C++ 需要自己管理 Socket、内存、指针,能暴露更多底层细节。Java/Python 有成熟的框架,开发快,但会屏蔽网络编程的本质。另外 C++ 在面试中更能体现对内存和系统调用的理解。
Q2:这个项目的架构有什么问题?
答:至少四个:
每个请求新建 MySQL 连接,应该用连接池;
预约事务没加行锁,并发下可能超卖;
SQL 字符串拼接有注入风险,应该用预处理语句;
客户端用固定缓冲区 recv,没处理 TCP 粘包/半包。
Q3:JSON 和 protobuf 怎么选?
答:JSON 可读性好、调试方便、语言无关,适合小规模和对外接口;protobuf 序列化后体积小、解析快、有强 schema,适合内部高性能 RPC。本项目请求量小,JSON 够用。
Q4:libevent 的 Reactor 模式是怎样的?
答:Reactor 核心是“事件循环 + 回调”。libevent 把 fd 和事件注册到 event_base,底层用 epoll/select 监听。事件就绪时,event_base_dispatch 调用对应回调。本项目里监听 socket 的回调负责 accept,连接 socket 的回调负责 recv 和业务处理。
七、一句话总结
C/S 三层架构的本质,是让客户端只管交互、服务端只管逻辑、数据库只管存储,三层通过 Socket + JSON 和 MySQL C API 两条管道串联起来。

