简介:这份面向C语言与MySQL初学者的图书管理系统文档,围绕控制台交互与数据库读写展开,适合刚接触数据库编程、希望用一个小项目串联基础语法与SQL操作的学习者。压缩包内仅含1个doc文档,约137KB,集中呈现系统实现思路与关键代码片段,便于直接阅读和对照理解。文档重点涉及借阅、归还、查询图书以及管理员添加、修改、删除图书等功能,并展示mysql_query、mysql_error、mysql_use_result、mysql_fetch_row等接口的调用方式,同时提醒结果集释放与简单循环菜单的组织方法。已有1574人学习,可作为课程设计、入门练习或数据库连接实验的参考,帮助读者理解C语言控制台程序如何与MySQL表结构配合完成状态更新、条件判断和查询结果输出。
1. 几百行控制台代码:C 语言 + MySQL 图书管理系统能跑起来的前提
很多初学者第一次接触数据库编程,都是从一份「C 语言 + MySQL 图书管理系统」的课程设计源码开始的:菜单打印在控制台,账号密码存在userinfo表,书名和状态存在book表,借书就是一条update。这份资源的全部逻辑压在一个main加两个函数里,没有框架、没有 ORM,mysql_query和mysql_fetch_row直接暴露在业务代码中。好处是整条链路一眼看到底,坏处是它把编译、链接、字符集、结果集释放这些环境问题全都攒在了一起。适合刚学完指针和结构体、想动手连一次真实数据库的人;如果你已经写过连接池,这份代码的价值更多在于复盘那些年踩过的坑。
2. 接上 MySQL C API:头文件顺序、编译命令与八个连接参数
把这份源码从 .doc 里复制出来,第一件事不是编译运行,而是把它变成能稳定构建的工程。原代码把所有#include堆在文件最前面,其中winsock.h和mysql.h的先后关系、库文件路径、位宽匹配,任何一项不对都会在编译阶段直接失败。
2.1 winsock.h 和 mysql.h 的包含顺序为什么要换
原代码的头部是这样的:
#include <stdio.h> #include <string.h> #include <winsock.h> /* 1.1 版,和 winsock2.h 冲突 */ #include <mysql.h>问题出在winsock.h上。MySQL 的客户端头文件在 Windows 平台会间接引入winsock2.h,而winsock.h与winsock2.h是两套互斥的声明,同时出现会刷出满屏的redefinition、conflicting types错误。更关键的是,这份业务代码从头到尾没有调用过一个 socket 函数——网络通信是 MySQL 客户端库内部的事,业务层根本不需要 include 它。
我一般改成这样:
#define WIN32_LEAN_AND_MEAN /* 让 windows.h 不再重复拉 winsock.h */ #include <winsock2.h> /* 必须排在 mysql.h 之前 */ #include <mysql.h> #include <stdio.h> #include <string.h> #include <stdlib.h>WIN32_LEAN_AND_MEAN的作用是裁剪windows.h里的旧网络头,避免它二次引入winsock.h;把winsock2.h放在mysql.h前面,则是因为mysql.h内部同样会包含它,靠 include guard 跳过重复展开。如果你的项目根本没用到任何 Windows 网络 API,最省事的做法是直接删掉winsock.h那一行,让mysql.h自己去处理依赖,这样能少踩一半编译错误。
提示:不要为了「看起来完整」而保留用不到的头文件。数据库客户端库的依赖应该在库这一层解决,业务源码保持干净。
2.2 一条命令把 libmysql 链进来:gcc 与 MSVC 两种写法
头文件解决之后是链接。MySQL 安装目录下通常有include、lib、bin三个子目录,lib里放libmysql.lib(导入库)和libmysql.dll(运行时库)。用 MinGW-w64 的 gcc 编译:
gcc book.c -o book.exe \ -I"D:/mysql-8.0/include" \ -L"D:/mysql-8.0/lib" \ -llibmysql三个参数的含义分别是:-I指定头文件搜索路径,对应#include <mysql.h>;-L指定导入库搜索路径;-llibmysql是 gcc 的库命名规则——去掉lib前缀和.lib/.a后缀,所以写-llibmysql而不是-lmysql。用 MSVC 的话:
cl book.c /I"D:\mysql-8.0\include" ^ /link /LIBPATH:"D:\mysql-8.0\lib" libmysql.libMSVC 的/link之后才是链接器参数,libmysql.lib要写全名。这里有个血泪经验:编译器和库的位宽必须一致。用 64 位的 gcc 去链 32 位的libmysql.lib,报错信息通常是undefined reference to 'mysql_init'而不是「位宽不匹配」,非常容易误判成路径写错。判断方法很简单,看安装目录名——很多发行包会带lib64或x64字样,没有的话用dumpbin /headers libmysql.lib看 machine 字段。
编译产物book.exe出来以后还没完。libmysql.dll必须能被运行时找到,两种做法:把它复制到book.exe同目录,或者把D:/mysql-8.0/lib加进系统 PATH。前者更适合做课程设计交付,双击就能跑,不依赖环境变量。
2.3 mysql_real_connect 的八个参数逐个对照
连接数据库的入口是mysql_real_connect,原型有八个参数,原代码用了 0 填充后三个:
MYSQL *pconn = mysql_init(NULL); if (pconn == NULL) { fprintf(stderr, "mysql_init failed\n"); return 1; } /* host, user, passwd, db, port, unix_socket, client_flag */ if (mysql_real_connect(pconn, "127.0.0.1", "root", "root", "bookinfo", 3306, NULL, 0) == NULL) { fprintf(stderr, "connect failed: %s\n", mysql_error(pconn)); mysql_close(pconn); return 1; }逐个说:host填127.0.0.1比localhost更少歧义,后者在部分客户端实现里会走命名管道;user和passwd是数据库账号,不是系统账号;db是库名,这里对应bookinfo;port传 3306 显式指定,传 0 表示走默认端口,两者等价但显式更利于排错;unix_socket在 Windows 上固定传 NULL;client_flag传 0 使用默认行为,需要压缩协议或忽略空格时才改。
两个细节值得单独拎出来。第一,mysql_init的返回值必须判空,内存不足时它返回 NULL,后面所有调用都会崩。第二,mysql_real_connect失败返回 NULL,但pconn这个句柄本身是有效的,仍然要mysql_close释放,否则每次重连都漏一小块内存。
连接建立之后紧接着是字符集协商,原代码写的是:
if (mysql_query(pconn, "set names gbk")) { printf("编码有错误为:%s\n", mysql_error(pconn)); }这条语句告诉服务端「我发过来的字节是 GBK,请按 GBK 解释」。它是否生效,取决于你的.c源文件本身存成什么编码——如果源文件是 UTF-8,字符串字面量「已借出」在内存里是 UTF-8 字节,再声明成 GBK,服务端就会按错误的规则转码,存进去是乱码。稳妥的搭配是:源文件存 GBK +set names gbk,或者源文件存 UTF-8 +set names utf8mb4+ 控制台chcp 65001。两套只能选一套,混着用必翻车。
3. 两张表的建模与登录:userinfo、book 的字段类型和鉴权分流
代码能编译只是过了第一关,表结构不对,后面每个功能都会以奇怪的方式失败。原代码对表结构的依赖是隐式的:select * from userinfo之后用row[1]取用户 ID,用insert into book values (...)不带列名,这些都要求建表顺序和代码里的假设完全一致。
3.1 建表 SQL:为什么 bid 不用自增、upwd 为什么是整型
先在 MySQL 里把库和表建出来:
CREATE DATABASE IF NOT EXISTS bookinfo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookinfo; CREATE TABLE userinfo ( uid INT PRIMARY KEY, uname VARCHAR(32) NOT NULL, upwd INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE book ( bid INT PRIMARY KEY, bname VARCHAR(64) NOT NULL, bauthor VARCHAR(64) NOT NULL, bstate VARCHAR(16) NOT NULL DEFAULT '未借出' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO userinfo VALUES (1001, 'admin', 123456), (1002, 'reader', 111111); INSERT INTO book VALUES (1, 'C程序设计语言', 'K&R', '未借出'), (2, '数据库系统概念', 'Silberschatz', '未借出');bid用INT PRIMARY KEY而不是AUTO_INCREMENT,是因为原代码的管理员添加图书分支要求用户手动输入编号,用自增反而会让「编号」这个字段在界面上失去意义。代价是编号冲突要靠主键约束兜底,插入重复编号时mysql_query会返回非 0,必须把mysql_error打印出来,否则用户只看到「添加失败」四个字。
upwd用INT是原代码scanf("%d", &pwd)的直接结果,好处是输入简单,坏处是密码只能是数字且明文存储。做课程设计够用,但只要这份代码要往真实场景挪一步,第一件该改的就是把它换成VARCHAR(64)存哈希值。bstate用VARCHAR(16)而不是TINYINT,是为了让查询结果直接打印出「已借出」「未借出」,省掉一层映射——这是教学代码的典型取舍,牺牲存储效率换可读性。
整库用utf8mb4而不是utf8,是因为书名里出现生僻字或特殊符号时,三字节的utf8会直接插入失败。这个坑在建表阶段不显眼,等到添加图书报Incorrect string value才发现就要重建表。
3.2 登录判定:mysql_num_rows 之前必须挡住的那次空指针
原代码的登录逻辑是这样:
sprintf(sql_login, "select * from userinfo where uid='%d' and upwd='%d'", account, pwd); if (mysql_query(pconn, sql_login)) { printf("登录失败"); } MYSQL_RES *res2 = mysql_store_result(pconn); int num = mysql_num_rows(res2);这里藏着一个必崩的路径:mysql_query出错时只打印了一行「登录失败」,没有return,代码继续往下执行mysql_store_result。查询失败时这个函数返回 NULL,而mysql_num_rows(NULL)属于未定义行为,多数情况下是直接段错误。正确写法要把错误分支彻底截断:
if (mysql_query(pconn, sql_login) != 0) { printf("查询出错: %s\n", mysql_error(pconn)); return; } MYSQL_RES *res = mysql_store_result(pconn); if (res == NULL) { printf("获取结果集失败: %s\n", mysql_error(pconn)); return; } my_ulonglong rows = mysql_num_rows(res); if (rows == 0) { mysql_free_result(res); printf("账号或密码错误\n"); return; } MYSQL_ROW row = mysql_fetch_row(res); printf("登录成功,欢迎 %s\n", row[1] ? row[1] : ""); mysql_free_result(res);注意mysql_query的返回值约定:0 表示成功,非 0 表示失败,所以判断条件写!= 0或直接if (mysql_query(...))都对,但不要写成== 0分支里去处理错误。另外mysql_num_rows的返回类型是my_ulonglong,用int接在结果集很大时会截断,虽然登录场景只有 0 或 1,养成用正确类型的习惯没坏处。
还有一个容易忽略的点:select *的列顺序由建表顺序决定,row[0]是uid,row[1]是uname,row[2]是upwd。原代码里那句int userid = atoi(row[1])取的是用户名而不是 ID,atoi("admin")得到 0。这行代码没有实际用途所以没暴露问题,但只要有人照抄去取用户 ID,就会拿到一堆 0。写死列名比select *安全得多。
3.3 用账号 1001 判管理员,这个硬编码该怎么拆
登录成功后的分流是这一句:
if (account == 1001) { adminfunction(pconn); } else { userfunction(pconn); }判断依据是「用户输入的账号是不是 1001」,而不是数据库里存的角色。这意味着只要有人把自己的账号改成 1001(或者注册时占用这个号),立刻拿到管理员权限。教学代码这么写是为了省一张表,但它把「认证」和「授权」两件事混成了一件。
拆法很简单,给userinfo加一列:
ALTER TABLE userinfo ADD COLUMN urole TINYINT NOT NULL DEFAULT 0 COMMENT '0=普通会员, 1=管理员'; UPDATE userinfo SET urole = 1 WHERE uid = 1001;登录时把urole一起查出来:
snprintf(sql_login, sizeof(sql_login), "select uid, uname, urole from userinfo where uid=%d and upwd=%d", account, pwd); /* ... 省略错误处理 ... */ MYSQL_ROW row = mysql_fetch_row(res); int role = atoi(row[2]); mysql_free_result(res); if (role == 1) { adminfunction(pconn, account); } else { userfunction(pconn, account); }这样角色由数据决定,新增管理员只需要改一行update,不用改代码重新编译。顺带把account传进两个功能函数,后面做「谁借了哪本书」的借阅记录表时,uid是必需的外键。
4. 借阅、归还与模糊查询:三条 SQL 背后的状态机与结果集
真正让这份源码值得逐行读的,是借阅分支。它同时踩中了「SQL 拼错」「结果集没取」「字符串比较用错对象」三个问题,而且这三个问题叠加之后的表现是「功能看起来正常,实际永远走不通」——最容易被误判成数据库没连上。
4.1 借阅分支里那句失效的状态校验
原代码的核心片段:
char jieyue[200]; sprintf(jieyue, "update book set bstate='已借出' where bid = %d", bid1); char state[200]; sprintf(state, "select bstaet from book where bid = %d", bid1); /* 字段名拼错 */ MYSQL_RES *res; res = mysql_store_result(pconn); /* 前面根本没执行 mysql_query */ if (strcmp(state, "已借出")) { /* 拿 SQL 语句和中文比较 */ printf("\n 此书已借出!\n"); goto ll; }三处问题拆开看。第一,bstaet是bstate的拼写错误,即使执行了这条 SQL 也会报Unknown column。第二,mysql_store_result之前没有对应的mysql_query,它拿到的是上一条查询残留的结果或者 NULL。第三,也是最要命的一处:strcmp(state, "已借出")比较的是「一条 SQL 语句字符串」和「中文字面量」,两者永远不相等,strcmp返回非 0,if条件恒为真——于是不管这本书当前是什么状态,程序都会打印「此书已借出」然后跳回菜单。
这就是为什么这段代码「能编译、能运行、界面正常」,但借阅功能一次都没成功过。判断这类问题的通用手法是:在分支入口打一行日志,把即将执行的 SQL 和mysql_query的返回值都打出来,如果 SQL 从未出现在日志里,问题就在拼装阶段而不在数据库。
4.2 先查后改:把判断塞进 where 条件里
修这段逻辑有两条路。一条是按原意「先查状态、再决定是否更新」,另一条是把状态判断直接写进update的where里,用受影响行数判断结果。后者更短、并发下也更安全,我一般用这种:
int bid = 0; printf("请输入图书编号: "); if (scanf("%d", &bid) != 1) { /* 输入非数字时清空缓冲 */ while (getchar() != '\n'); printf("编号必须是数字\n"); return; } char sql[256]; snprintf(sql, sizeof(sql), "update book set bstate='已借出' where bid=%d and bstate='未借出'", bid); if (mysql_query(pconn, sql) != 0) { printf("借阅失败: %s\n", mysql_error(pconn)); return; } my_ulonglong affected = mysql_affected_rows(pconn); if (affected == 0) { /* 0 行受影响有两种可能:书不存在,或者已经被借走 */ printf("该书不存在或已借出\n"); } else { printf("借阅成功\n"); }关键在where后面的and bstate='未借出'。它把「检查」和「修改」压成一条原子语句,两个人同时点借阅时,数据库的行锁保证只有一个update能匹配到行,另一个的affected_rows是 0。如果按「先select再update」的写法,两条语句之间存在时间窗口,并发下会出现两次都判断为「未借出」然后双双更新的情况。
mysql_affected_rows返回的是实际被修改的行数,不是匹配行数。MySQL 默认行为下,如果新值和旧值相同(比如把已经是「已借出」的书再改成「已借出」),受影响行数是 0。这一点在归还逻辑里要特别注意,见下一节。
4.3 mysql_use_result 和 mysql_store_result 怎么选
原代码在查询功能里用了mysql_use_result:
res = mysql_use_result(pconn); while ((row = mysql_fetch_row(res)) != NULL) { for (int t = 0; t < mysql_num_fields(res); t++) printf("%8s ", row[t]); printf("\n"); } mysql_free_result(res);这两个取结果的函数差别很大,选错了会出现「查完一本书之后所有操作都报错」的诡异现象:
| 函数 | 数据落点 | 内存占用 | 取数期间能否发新查询 | 典型场景 |
|---|---|---|---|---|
mysql_store_result | 一次性全部拉到客户端 | 与结果集成正比 | 可以 | 结果集小、需要mysql_num_rows |
mysql_use_result | 逐行从服务端读取 | 只占一行 | 不可以 | 超大结果集、顺序处理完就丢 |
mysql_use_result的本质是「流式」:服务端还在一行行往回送,客户端必须把mysql_fetch_row调到返回 NULL 为止,或者至少把结果集free掉,否则连接上残留着未读完的数据,下一条mysql_query会返回Commands out of sync; you can't run this command now。图书管理系统的数据量最多几百行,mysql_store_result完全够用,还能顺手拿到行数做分页,没必要为了省几 KB 内存去用流式接口。
另外别忘了mysql_free_result。原代码的登录分支里,res2从头到尾没有释放,每次登录失败重试都漏一块。这类泄漏在短命的课程设计里看不出来,但把同样的写法搬到常驻服务里,跑几天内存就上去了。
4.4 模糊搜索的缓冲区、转义与 scanf 边界
搜索分支的问题更隐蔽:
char query1[20]; char temp[20]; printf("请输入搜索图书信息:"); scanf("%s", temp); sprintf(query1, "select * from book where bname like '%%%s%%'", temp);query1只有 20 字节,而select * from book where bname like '%关键词%'这条语句光固定部分就超过 40 字节。用户只要输入任意关键词,sprintf就会写越界——栈上的返回地址被覆盖,表现是「搜完之后程序莫名其妙退出」或者「菜单文字变成乱码」。这是最典型的一类翻车,编译器不一定警告,运行时也不一定立刻崩。
改法是三层防护:缓冲区给足、用snprintf限长、对用户输入里的单引号做转义。
char kw[64]; printf("请输入书名关键词: "); if (scanf("%63s", kw) != 1) return; /* 宽度限制防止 kw 溢出 */ /* 单引号翻倍,避免拼出非法 SQL */ char esc[128]; size_t j = 0; for (size_t i = 0; kw[i] != '\0' && j < sizeof(esc) - 2; i++) { if (kw[i] == '\'') { esc[j++] = '\''; esc[j++] = '\''; } else { esc[j++] = kw[i]; } } esc[j] = '\0'; char sql[512]; snprintf(sql, sizeof(sql), "select bid, bname, bauthor, bstate from book " "where bname like '%%%s%%'", esc);scanf("%63s", kw)里的 63 不是随便写的,它等于缓冲区长度减一,保证末尾还有位置放\0。单引号翻倍是 SQL 标准的转义方式,能挡住' or '1'='1这类输入。snprintf的第二个参数是缓冲区总长度,它保证最多写sizeof(sql) - 1个字符并自动补零,超出部分截断而不是越界——截断之后 SQL 语法会错,mysql_query返回非 0,你能从mysql_error里看到问题,总比栈被冲掉强。
注意:手写转义只能应急。真实项目里应该用
mysql_real_escape_string,它依赖当前连接的字符集,比手写循环正确处理多字节字符。
5. 避坑:五个能把初学者卡一整晚的报错
环境类问题的共同特征是报错信息指向的位置和真实原因相距很远。下面五条是我在帮人看这类代码时出现频率最高的,每条按「现象 → 原因 → 解决」的顺序写,遇到时按顺序对照即可。
5.1 编译与链接阶段的三个坑
现象一:编译时刷出几十行winsock2.h: redefinition of 'struct timeval'、conflicting types for 'fd_set'。
原因:winsock.h和winsock2.h被同时引入,两套头文件对同一批类型做了不同声明。这份源码里winsock.h是自己写的,winsock2.h是mysql.h间接拉进来的。
解决:删掉#include <winsock.h>。如果确实需要 socket,改成#define WIN32_LEAN_AND_MEAN加#include <winsock2.h>并放在mysql.h之前。改完清一次构建缓存,别让旧的预处理结果干扰判断。
现象二:链接阶段报undefined reference to 'mysql_init'(gcc)或LNK2019: unresolved external symbol _mysql_init(MSVC),但头文件明明能找到,mysql.h里的声明也看得见。
原因:头文件路径和库路径是两回事。-I只解决编译期的声明查找,链接期还需要-L加-llibmysql找到实际实现。另一种常见原因是位宽不匹配——64 位工具链配 32 位导入库,符号名对不上,报错形式和新手最怕的「库没链上」一模一样。
解决:先确认链接参数完整,再确认位宽。用gcc -v看目标平台,用dumpbin /headers libmysql.lib | findstr machine看库的平台,两者必须都是 x64 或都是 x86。
现象三:编译链接全部通过,双击book.exe弹窗提示「无法启动此程序,因为计算机中丢失 libmysql.dll」,或者命令行里直接闪退。
原因:libmysql.dll是运行时动态加载的,链接期的.lib只是导入表。系统按「exe 所在目录 → 系统目录 → PATH」的顺序查找 dll,MySQL 的bin目录通常不在 PATH 里。
解决:把libmysql.dll复制到book.exe同目录。分发课程设计时连 dll 一起打包,别人双击就能跑。不要图省事去改系统 PATH,换台机器照样失效。
5.2 连接与字符集的两个坑
现象四:程序启动后连接失败,mysql_error打印Authentication plugin 'caching_sha2_password' cannot be loaded或Client does not support authentication protocol。
原因:MySQL 8.0 默认认证插件是caching_sha2_password,而项目里链接的客户端库版本较老,不认识这个插件。
解决:两条路。一是把账号的认证方式改回兼容插件:
ALTER USER 'root'@'127.0.0.1' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;二是换成与数据库版本匹配的客户端库。课程设计场景下第一种更快,但要知道这是降级兼容,不是修复。改完记得重启客户端程序,连接是启动时建立的。
现象五:数据能查出来,但中文全是???或者一串æ›´æ–°之类的怪字符,英文和数字正常。
原因:链路上有三处编码可能不一致——源文件保存编码、set names声明的编码、表和列的字符集。三处只要有一处不同,中文就会在转换过程中失真。原代码用set names gbk,如果你的.c文件是 UTF-8 保存的,字符串字面量按 UTF-8 编码进内存,却被声明成 GBK 发给服务端,必然乱码。
解决:三处统一。方案 A:源文件存 ANSI/GBK,保留set names gbk,控制台默认代码页 936。方案 B:源文件存 UTF-8,把语句改成set names utf8mb4,并在程序启动时调用system("chcp 65001 > nul")切控制台代码页。选一套执行到底,中途不要混。验证方法是插入一条纯中文书名,用命令行客户端select出来看是否正常,能正常显示说明服务端存对了,问题就只剩控制台输出这一环。
6. 从「能跑」到「敢改」:把 goto 菜单收成状态返回值
这份代码用goto ll和goto kk做菜单循环,短是短,但一旦要在循环里加「返回上一级」或者「操作完停留三秒」,goto就撑不住了——它只能跳到固定标签,没法表达层级。我通常的做法是把每个功能函数改成返回一个状态码,菜单循环根据状态码决定继续还是退出:
| 返回值 | 含义 | 菜单层处理 |
|---|---|---|
| 0 | 操作完成,继续当前菜单 | 刷新菜单重新等待输入 |
| 1 | 用户主动退出 | break出循环,返回上一层 |
| -1 | 数据库错误 | 打印mysql_error后继续,不退出 |
enum { MENU_OK = 0, MENU_EXIT = 1, MENU_DBERR = -1 }; static int borrow_book(MYSQL *conn, int uid) { /* ... 省略输入校验 ... */ if (mysql_query(conn, sql) != 0) { printf("借阅失败: %s\n", mysql_error(conn)); return MENU_DBERR; /* 错误不吞掉,交给上层决定 */ } return MENU_OK; } void user_menu(MYSQL *conn, int uid) { int choice = 0; while (1) { printf("\n1-借阅 2-归还 3-查询 0-退出\n请选择: "); if (scanf("%d", &choice) != 1) { while (getchar() != '\n'); continue; } if (choice == 0) break; switch (choice) { case 1: borrow_book(conn, uid); break; case 3: query_book(conn); break; default: printf("无效选项\n"); } } }这样改的好处是可测:borrow_book不依赖全局状态,传入一个连接句柄就能单独调用,写个测试程序造两条数据就能验证「重复借阅返回 0 行受影响」这条分支。原来那段goto代码没法单独调用,只能靠人肉点菜单,测一次五分钟。
再补一个验证习惯。每次改动数据库相关代码,我会固定跑一遍这四步:先插入一本状态为「未借出」的书,借一次看是否成功,再借一次看是否提示已借出,最后归还再借一次。这四步覆盖了状态机的全部迁移路径,比随手点两下可靠得多。血泪经验是:这套流程我坚持了几年,中间有次图快跳过,结果上线后发现update的where少了个and,整张表的书籍状态被一条语句刷成了「已借出」。从那以后我每次动 SQL 都强制走一遍这四步,宁可慢五分钟,也不在凌晨两点回滚数据。希望帮到你。
本文还有配套的精品资源,点击获取