一、模块概述
数据库模块负责用户数据的持久化存储,包括用户注册、登录验证、信息查询和战绩更新。本模块包含两个文件:
| 文件 | 功能说明 |
|---|---|
| db.sql | 数据库建表脚本 — 定义用户表结构 |
| db.hpp | 用户表操作类 — 封装所有数据库增删改查操作 |
模块依赖关系:
logger.hpp(日志) ↓ util.hpp(mysql_util / json_util) ↓ db.hpp(本模块,用户表 CRUD 操作) ↓ room.hpp / matcher.hpp / server.hpp(上层模块通过 db.hpp 操作用户数据)二、数据库表设计 — db.sql
2.1 完整建表脚本
dropdatabaseifexistsgobang;createdatabaseifnotexistsgobang;usegobang;createtableifnotexistsuser(idintprimarykeyauto_increment,usernamevarchar(32)uniquekeynotnull,passwordvarchar(128)notnull,scoreint,total_countint,win_countint);2.2 字段说明
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | int | 主键,自增 | 用户唯一标识 |
| username | varchar(32) | 唯一,非空 | 用户名,不可重复 |
| password | varchar(128) | 非空 | 密码,存储 MD5 哈希值 |
| score | int | 无 | 天梯分数,注册默认 1000 |
| total_count | int | 无 | 总对战场次,注册默认 0 |
| win_count | int | 无 | 胜利场次,注册默认 0 |
2.3 设计要点
auto_increment:用户 ID 由数据库自动分配,从 1 递增,避免并发冲突unique key:用户名唯一约束,数据库层面保证不会出现重复注册password长度 128:存储 MD5 哈希后的密码(32 字符十六进制),留足余量score默认 1000:新注册用户从 1000 分起步,匹配系统以此划分段位
三、用户表操作类 — db.hpp
3.1 类结构总览
classuser_table{private:MYSQL*_mysql;// MySQL 操作句柄std::mutex _mutex;// 互斥锁,保护数据库并发访问public:user_table(host,username,password,dbname,port);// 构造:建立连接~user_table();// 析构:关闭连接boolinsert(Json::Value&user);// 注册:新增用户boollogin(Json::Value&user);// 登录:验证账号密码,返回用户信息boolselect_by_name(name,user);// 按用户名查询boolselect_by_id(id,user);// 按用户ID查询boolwin(uint16_tid);// 胜利:分数+30,场次+1,胜场+1boollose(uint16_tid);// 失败:分数-30,场次+1};3.2 常见问题
互斥锁与读写锁有啥区别?
- 互斥锁:任意时刻只允许一个线程访问共享资源,不管是读操作还是写操作,全部互斥串行执行。
- 读写锁:分为读与读;读与写,写与写。 前者可以同时共享访问,不互斥; 后两者互斥排他,同一时间只能有一个写线程。
为什么本项目选着互斥锁?
- 共享资源写操作频繁、读写比例差不多;
- 业务操作执行时间很短,没必要做读并行优化;
- 要求代码简单、逻辑不容易出错、避免写饥饿问题;
- 强时序业务(如游戏落子、状态流转)必须串行,不能并发读。
什么时候用读写锁?
- 读多写极少,大量线程频繁读、很少修改;
- 读操作耗时较长,希望多线程同时读来提升性能;
- 允许写操作偶尔等待,不要求即时抢占执行。
3.3 构造与析构
user_table(conststd::string&host,conststd::string&username,conststd::string&password,conststd::string&dbname,uint16_tport=3306){_mysql=mysql_util::mysql_create(host,username,password,dbname,port);assert(_mysql!=NULL);// 连接失败直接断言终止}~user_table(){mysql_util::mysql_destroy(_mysql);_mysql=NULL;}在util工具类中创建一个Mysql需要传入的数据
- 构造时通过
mysql_util::mysql_create建立数据库连接,assert保证连接必须成功 - 析构时通过
mysql_util::mysql_destroy释放连接资源 - 整个程序生命周期内只有一个连接,通过互斥锁保护并发访问
3.3 用户注册 — insert
boolinsert(Json::Value&user){#defineINSERT_USER"insert into user values(null, '%s', MD5('%s'), 1000, 0, 0);"if(user["username"].isNull()||user["password"].isNull()){DLOG("INPUT PASSWORD OR USERNAME");returnfalse;}charsql[4096]={0};sprintf(sql,INSERT_USER,user["username"].asCString(),user["password"].asCString());std::unique_lock<std::mutex>lock(_mutex);boolret=mysql_util::mysql_exec(_mysql,sql);if(ret==false){DLOG("insert user info failed!!\n");returnfalse;}returntrue;}解析:
入参校验:检查用户名和密码字段是否为空,防止插入无效数据
SQL 模板:
insert into user values(null, '%s', MD5('%s'), 1000, 0, 0)null— ID 自动递增MD5('%s')— 密码在 SQL 层面直接做 MD5 哈希,数据库中存储的是哈希值而非明文(密码加密)1000, 0, 0— 新用户默认分数 1000,场次 0,胜场 0
sprintf 拼接 SQL:将用户名和密码填入模板,生成完整的 SQL 语句
- sprintf 只吃 C 字符串,C++ 字符串它不认,所以必须手动转。
注册失败通常是因为用户名重复(
unique key约束)
3.4 用户登录 — login
boollogin(Json::Value&user){#defineLOGIN_USER"select id, score, total_count, win_count from user where username='%s' and password=MD5('%s');"charsql[4096]={0};sprintf(sql,LOGIN_USER,user["username"].asCString(),user["password"].asCString());MYSQL_RES*res=NULL;{std::unique_lock<std::mutex>lock(_mutex);boolret=mysql_util::mysql_exec(_mysql,sql);if(ret==false){returnfalse;}res=mysql_store_result(_mysql);if(res==NULL){returnfalse;}}introw_num=mysql_num_rows(res);if(row_num!=1){returnfalse;}MYSQL_ROW row=mysql_fetch_row(res);user["id"]=std::stol(row[0]);user["score"]=std::stol(row[1]);user["total_count"]=std::stol(row[2]);user["win_count"]=std::stol(row[3]);mysql_free_result(res);returntrue;}解析:
登录是本模块最复杂的方法,核心流程分为三步:
第一步 — 执行查询:
SQL 语句同时以用户名和 MD5 后的密码作为查询条件。如果用户名或密码任一不匹配,查询结果为空,登录失败。
第二步 — 加锁获取结果集:
std::unique_lock<std::mutex>lock(_mutex);boolret=mysql_util::mysql_exec(_mysql,sql);res=mysql_store_result(_mysql);这是本模块线程安全的关键。因为多个线程可能同时调用 login、select 等方法共享同一个
_mysql连接,MySQL C API 的mysql_store_result必须紧跟在mysql_query之后,中间不能插入其他查询,否则结果集会错乱。所以用_mutex将「执行查询 + 获取结果」作为原子操作保护起来。
只锁【发送 SQL + 获取结果】这两步。
原因:
- MySQL 连接必须独占
- 拿到结果集 res 之后,就和 MySQL 连接无关了
- 锁范围越小,多线程性能越好,不卡玩家
第三步 — 解析结果行:
introw_num=mysql_num_rows(res);// 获取匹配的行数if(row_num!=1){returnfalse;}// 必须恰好一行,0=账号密码错误,>1=数据异常MYSQL_ROW row=mysql_fetch_row(res);// 取出第一行数据user["id"]=std::stol(row[0]);// row[0] = iduser["score"]=std::stol(row[1]);// row[1] = score// ...mysql_free_result(res);// 释放结果集内存MYSQL_ROW本质是char**,row[0]、row[1]分别对应 SELECT 中的第 1、2 个字段stol把字符串(char /string)转换成 长整型数字(long)- 查询结果写入传入的
Json::Value对象,供上层模块使用 mysql_free_result释放结果集占用的内存,忘记调用会导致内存泄漏
3.5 按用户名查询 — select_by_name
boolselect_by_name(conststd::string&name,Json::Value&user){#defineUSER_BY_NAME"select id, score, total_count, win_count from user where username='%s';"// ... 与 login 类似的加锁查询流程user["id"]=std::stol(row[0]);user["username"]=name;user["score"]=std::stol(row[1]);user["total_count"]=std::stol(row[2]);user["win_count"]=std::stol(row[3]);mysql_free_result(res);returntrue;}与login的区别:
- 只按用户名查询,不验证密码
- 结果中额外包含
username字段(login 中调用者已知道用户名,不需要返回)
使用场景:匹配模块需要根据用户名获取分数来分配匹配队列。
3.6 按用户ID查询 — select_by_id
// 通过用户id获取用户信息boolselect_by_id(uint16_tid,Json::Value&user){#defineUSER_BY_ID"select username, score, total_count, win_count from user where id='%d';"charsql[4096]={0};sprintf(sql,USER_BY_ID,id);// 存放数据库查询结果MYSQL_RES*res=NULL;// 加锁:保护MySQL连接,多线程安全{std::unique_lock<std::mutex>lock(_mutex);boolret=mysql_util::mysql_exec(_mysql,sql);if(ret==false){DLOG("get user by id failed!!\n");returnfalse;}// 获取结果集res=mysql_store_result(_mysql);if(res==NULL){DLOG("have no user info!!");returnfalse;}}// 必须只能查到 1 条数据(id是主键,唯一)introw_num=mysql_num_rows(res);if(row_num!=1){DLOG("the user information queried is not unique!!");mysql_free_result(res);returnfalse;}// 解析数据,存入 user 对象MYSQL_ROW row=mysql_fetch_row(res);user["id"]=id;user["username"]=row[0];user["score"]=std::stol(row[1]);user["total_count"]=std::stol(row[2]);user["win_count"]=std::stol(row[3]);// 释放数据库结果集内存mysql_free_result(res);returntrue;}与select_by_name结构相同,区别是以id作为查询条件,SELECT 的第一个字段变为username。
使用场景:匹配模块根据 WebSocket 连接对应的用户 ID 获取分数和用户名。
3.7 胜负记录更新 — win / lose
// 胜利:分数+30,总场次+1,胜场+1boolwin(uint16_tid){#defineUSER_WIN"update user set score=score+30, total_count=total_count+1, win_count=win_count+1 where id=%d;"charsql[4096]={0};sprintf(sql,USER_WIN,id);std::unique_lock<std::mutex>lock(_mutex);boolret=mysql_util::mysql_exec(_mysql,sql);if(ret==false){returnfalse;}returntrue;}// 失败:分数-30,总场次+1boollose(uint16_tid){#defineUSER_LOSE"update user set score=score-30, total_count=total_count+1 where id=%d;"charsql[4096]={0};sprintf(sql,USER_LOSE,id);std::unique_lock<std::mutex>lock(_mutex);boolret=mysql_util::mysql_exec(_mysql,sql);if(ret==false){returnfalse;}returntrue;}解析:
- 使用 SQL 的自增/自减语法(
score=score+30),无需先查询再更新,一条语句原子完成 - 胜利和失败都会增加
total_count,只有胜利增加win_count - 每局分值变化为+30/-30,影响用户段位划分(匹配模块以此分三档:<2000 / 2000~3000 / ≥3000)
使用场景:房间模块判定胜负后调用,在
room.hpp的handle_request中:
if(json_resp["winner"].asUInt64()!=0){uint64_twinner_id=json_resp["winner"].asUInt64();uint64_tloser_id=winner_id==_white_id?_black_id:_white_id;_tb_user->win(winner_id);_tb_user->lose(loser_id);_status=GAME_OVER;}四、设计总结
| 设计点 | 实现方式 |
|---|---|
| 密码安全 | SQL 层 MD5() 哈希,数据库不存明文 |
| 用户名唯一 | 数据库 unique key 约束 |
| 并发安全 | std::mutex 保护 exec + store_result 的原子性 |
| 结果集管理 | mysql_free_result 防止内存泄漏 |
| 数据传递 | 统一使用 Json::Value 作为数据容器,与上层模块一致 |
| 默认初始值 | 注册时 SQL 中写死 1000/0/0,无需额外代码 |
五、回顾
- 本模块基于底层
util.hpp的MySQL工具接口,完成五子棋项目用户数据的完整持久化管理; - 通过
db.sql脚本初始化用户数据表,利用主键、唯一约束保证数据规范。 - db.hpp封装了用户
注册、登录校验、按用户名与用户ID查询用户信息、对局胜负战绩分数更新等全套数据库CRUD操作。 - 模块采用互斥锁保障多线程下共用MySQL连接的并发安全,密码通过SQL层MD5哈希存储保障安全,同时规范管理MySQL结果集避免内存泄漏;
- 统一以
Json::Value作为数据容器和上层模块完成数据交互。
一句话总结:本数据库模块实现了用户数据存储、查询、校验与战绩更新全流程,兼顾数据安全、线程安全,为匹配、房间、对战等上层业务提供稳定的数据支撑。