1. 变量体系:存储过程里的“记忆盒子”
聊MySQL存储过程,绕不开的第一座山就是变量。可以这么说,变量没搞懂,后面看异常处理、游标、流程控制全像看天书。而这次要讲的主题把变量、中断处理、流程控制放在同一章,确实是存储过程编程里最核心的“铁三角”。
1.1 用户变量、系统变量、局部变量,到底该用哪个?
很多初学者一上来就被三种变量搞晕:用户变量、系统变量、局部变量,名字都带“变量”,用起来却天差地别。我直接用一张表把它们的核心区别列清楚,后面展开讲各自的用法。
| 变量类型 | 作用域 | 生命周期 | 声明方式 | 典型用途 |
|---|---|---|---|---|
| 用户变量 | 当前会话 | 会话结束即销毁 | 直接用 @变量名 赋值 |
跨存储过程传递临时值、调试 |
| 系统变量 | 全局/会话 | 服务器运行期间有效 | 无需声明,直接修改 | 配置连接超时、隔离级别等 |
| 局部变量 | 存储过程内部 | 存储过程执行结束即销毁 | DECLARE 变量名 类型 |
存中间结果、循环计数器 |
先说用户变量。它最典型的特征是带 @ 前缀,比如 SET @total = 0。用户变量的作用域是整个会话,也就是说只要你的客户端连接不断,这个变量在多个存储过程之间都能读到。很多人在写存储过程的时候,喜欢用用户变量暂存计算结果,图省事,这点我不反对。但要注意,用户变量在并发高的场景下容易踩坑:如果同一个连接里嵌套调用多个存储过程,变量值很容易被意外覆盖。
然后是系统变量。它分 GLOBAL 和 SESSION 两个级别,比如 max_connections、transaction_isolation。在存储过程内部,我们一般不太会去改系统变量,除非有特殊需求,比如在过程里临时把隔离级别改成 READ COMMITTED 解决死锁问题。改完记得在结束时恢复,不然会殃及整个会话。
最后是局部变量,它才是存储过程的主角。局部变量要用 DECLARE 在过程体开头声明,可以指定类型(INT、VARCHAR、DECIMAL等)和默认值。它只在当前 BEGIN...END 块里有效,块执行完变量就销毁,隔离性很好。我的建议是:能用局部变量就不要用用户变量。局部变量的作用域清晰,不污染会话状态,排查问题也容易。
1.2 变量赋值的三种姿势和隐藏的坑
赋值是变量使用的关键环节,MySQL里常见的有三种方式。第一种是 SET 直接赋值,最简单也最直白:
sql复制SET @total_price = 0;
SET @total_price = @total_price + 100;
第二种是 SELECT ... INTO 子句,适合把查询结果赋给变量。比如在存储过程中统计订单总数:
sql复制DECLARE order_count INT;
SELECT COUNT(*) INTO order_count FROM orders;
但这里有个非常隐蔽的坑:如果 SELECT 没有返回任何行,变量不会被赋值,它会保留原来的值(或者保持 NULL)。这在业务判断时容易出问题。比如你在过程里先给变量初始化成 0,然后执行 SELECT ... INTO 查出 0 条记录,此时变量依然是 0,看起来没问题。但如果你没有初始化,变量就是 NULL,后面一参与算术运算,结果直接变成 NULL,整个流程就乱了。
第三种是 SELECT 语句里直接用 := 赋值,典型场景是在普通SQL里实现类似“累计求和”的需求:
sql复制SELECT @running_total := @running_total + amount FROM orders ORDER BY id;
这里要注意,:= 在 MySQL 中表示赋值,而 = 在 SET 语句和 UPDATE 语句里有不同的含义。我见过不少新手在 SELECT 里用 = 赋值不生效,折腾半天才反应过来是写法问题。
另外一个高频翻车点:局部变量声明位置必须是 BEGIN 块的开头。MySQL要求 DECLARE 语句必须在其他语句之前,如果你在中间穿插了 SET 再 DECLARE,就会直接报语法错误。有些程序员习惯了其他语言的“随用随声明”,在 MySQL 存储过程里很容易被这个限制恶心到。
1.3 变量运算的套路与陷阱
变量能参与算术运算,这本来是好事,但 MySQL 有一个很经典的小陷阱,就是整数溢出。比如你定义 DECLARE n INT DEFAULT 2147483647; 然后执行 SET n = n + 1;,在有些版本/模式的组合下,结果会是 -2147483648,典型的溢出回绕。这就是热词里那个“mysql中int+5”背后的核心知识点——int的边界。处理办法很简单:根据数据量预估数值范围,选对类型。金额类用 DECIMAL,计数类如果可能超过20亿,用 BIGINT,不要为了省空间强行用 INT。
此外,字符拼接也常见。MySQL 里字符串拼接不能用 +,要用 CONCAT() 函数。我看到过有人写 SET @info = '用户ID: ' + @user_id; 然后得到一堆 0 或者报错,其实就是没搞懂 SQL 里的 + 只做加法。正确写法是:
sql复制SET @info = CONCAT('用户ID: ', @user_id);
每一条看起来都是小知识,但组合起来的杀伤力很大。我在帮同事评审存储过程代码时发现,一半以上的 bug 都出在变量类型和赋值方式上,而不是业务流程本身。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 中断处理机制:如何优雅地“接住”异常
有人问,什么叫“中断处理”?我之前带新人时最爱用的类比就是:存储过程就像一条生产线,正常流程是零件按顺序往下传。但如果某个环节设备故障了,你不做任何处理,整条流水线直接瘫痪,工厂也不知道到底哪个环节出了问题。中断处理机制就像是在关键工位装了传感器,一旦异常发生,它会触发一个预设的“应急方案”:要么跳过故障件继续生产(CONTINUE),要么拉响警报整线停止(EXIT)。
2.1 异常处理的声明语法和条件类型
MySQL 的异常处理依赖于四个关键字构造的声明:
sql复制DECLARE handler_type HANDLER FOR condition_value [, condition_value] ... statement
handler_type 只有两个值:CONTINUE 和 EXIT。前者表示触发后继续执行后续语句,后者表示触发后该 BEGIN...END 块直接结束。
condition_value 可以是具体数值或类别,常见的有这几种:
SQLSTATE 'value':指定具体的 SQLSTATE 码,比如'23000'表示主键冲突/唯一索引冲突SQLEXCEPTION:捕获 SQLSTATE 类中以非“00”、“01”、“02”开头的所有异常SQLWARNING:捕获所有以“01”开头的警告NOT FOUND:捕获以“02”开头的状态,最典型的就是SELECT/游标取数取不到记录
从经验上看,业界90%的存储过程用到最多的是 SQLEXCEPTION 和 NOT FOUND。前者做兜底异常捕获,后者和游标配合处理“遍历到末尾”的场景。
2.2 CONTINUE与EXIT:两种应急策略怎么选
在选择 CONTINUE 还是 EXIT 时,很多新手会纠结。我给出一个很直接的判断标准:这个异常出现之后,后面的逻辑还有没有意义?
如果是“数据不存在”这类非致命问题,比如从明细表里根据订单ID查询一条记录,查不到就用默认值继续,那么用 CONTINUE 再好不过,执行完这块逻辑后接着往后跑。
如果是“主键冲突”、“死锁”、“事务回滚”这类严重问题,用 EXIT 通常更安全。因为一旦出现这种异常,后续语句继续执行大概率会产生脏数据或者重复报错。我自己的习惯是:存储过程一旦遇到异常,就往外层抛,由调用方统一处理,过程内部尽量保持简洁。
这里有一个容易疏忽的点:DECLARE ... HANDLER 只对当前 BEGIN...END 块内的后续语句生效。如果你在过程里嵌入了子块(BEGIN...END 嵌套),子块内即使有异常,也不会触发父块的 handler,除非子块自己声明了。这个作用域特性和变量的作用域逻辑类似,写的时候要格外注意。
2.3 实战:写一个带异常日志的记录器
光讲概念不容易记住,我来分享一个我常用的小工具:在存储过程里捕获异常并把错误信息插入日志表。第一步,建一个异常日志表:
sql复制CREATE TABLE proc_error_log (
id INT AUTO_INCREMENT PRIMARY KEY,
proc_name VARCHAR(64),
error_msg VARCHAR(255),
error_time DATETIME
);
然后写一个带异常处理的存储过程:
sql复制DELIMITER //
CREATE PROCEDURE sp_insert_order(IN order_id INT, OUT result_msg VARCHAR(255))
BEGIN
DECLARE err_msg VARCHAR(255);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
GET DIAGNOSTICS CONDITION 1 err_msg = MESSAGE_TEXT;
INSERT INTO proc_error_log(proc_name, error_msg, error_time)
VALUES('sp_insert_order', err_msg, NOW());
SET result_msg = CONCAT('插入失败: ', err_msg);
END;
INSERT INTO orders(id, create_time) VALUES(order_id, NOW());
SET result_msg = '插入成功';
END//
DELIMITER ;
这个例子里最值得说的是 GET DIAGNOSTICS。它是在 MySQL 5.6 之后支持的语法,用于获取详细的错误信息,语法为 GET DIAGNOSTICS CONDITION 1 err_msg = MESSAGE_TEXT;。有了它,异常处理不只是“知道出错了”,还能把具体错误原因记录下来,排查问题能省很多力气。
这里我踩过一个坑:GET DIAGNOSTICS 必须在异常触发后立即调用,如果你在 handler 里先执行了别的 SQL,诊断信息可能被后续语句覆盖。所以我的习惯是 handler 里的第一句话就获取错误信息,然后再做日志插入。
3. 流程控制:过程化的骨架与灵魂
变量和异常处理是血肉,流程控制才是骨架。MySQL 存储过程的流程控制和常见编程语言差不多,主要包括 IF、CASE、LOOP、WHILE、REPEAT、LEAVE、ITERATE 这几类。很多人说 MySQL 存储过程写起来“脏”,很大一部分原因就是流程控制语句用得不熟练,导致代码一团乱麻。
3.1 IF与CASE:条件判断的两把钥匙
IF 的语法非常直白:
sql复制IF condition THEN
statement;
ELSEIF condition THEN
statement;
ELSE
statement;
END IF;
注意,MySQL 里 ELSEIF 是一个词,没有空格,写成 ELSE IF 直接报错。这个细节逼疯过不少人。
CASE 有两种写法。第一种是简单 CASE,拿一个表达式的结果去匹配:
sql复制CASE status
WHEN 1 THEN SET desc = '待支付';
WHEN 2 THEN SET desc = '已支付';
ELSE SET desc = '未知';
END CASE;
第二种是搜索式 CASE,可以写布尔表达式:
sql复制CASE
WHEN total > 1000 THEN SET level = 'VIP';
WHEN total > 100 THEN SET level = '普通';
ELSE SET level = '新客';
END CASE;
从执行效率来看,简单 CASE 在匹配场景下更清晰,逻辑可读性也更好;搜索式 CASE 则适合边界条件复杂的场景。我的习惯是:分支小于等于3个用 IF,分支较多或按值匹配用 CASE。CASE 还有一个微妙的注意点:如果 WHEN 分支都没有命中而你也没有写 ELSE,程序会报 Case not found 错误。这个和 IF 默认走空逻辑不同,是新手很容易踩的雷。
3.2 循环三兄弟:LOOP、WHILE、REPEAT 怎么选
WHILE 是“先判断,后执行”,即条件为真才进入循环体:
sql复制WHILE i <= 10 DO
SET i = i + 1;
END WHILE;
REPEAT 是“先执行,后判断”,也就是无条件执行一次后再判断 UNTIL 条件是否为真:
sql复制REPEAT
SET i = i + 1;
UNTIL i >= 10
END REPEAT;
LOOP 本身没有循环结束条件,需要在循环体内部用 LEAVE 手动控制跳出:
sql复制loop_label: LOOP
SET i = i + 1;
IF i >= 10 THEN
LEAVE loop_label;
END IF;
END LOOP;
实际开发中,三者的选择有以下经验可以参考:
- 遍历游标或者动态结果集,优先用
LOOP+LEAVE,因为循环次数不确定,跳出时机掌握在代码里 - 计数循环,比如从1加到100,用
WHILE最符合直觉 - 需要“至少执行一次”的轮询逻辑,用
REPEAT,但这种情况在业务存储过程中其实很少见
3.3 LEAVE与ITERATE:break和continue的MySQL版
如果你熟悉 C 或 Java,对比着看会更容易理解。LEAVE 对应 Java 中的 break,用于退出循环或者某个带标签的代码块;ITERATE 对应 continue,跳过本轮循环剩余语句,直接进入下一轮。
这里有一个细节:LEAVE 不仅能跳出 LOOP/WHILE/REPEAT 循环,还能跳出带标签的 BEGIN...END 块。这种用法在异常处理多级嵌套时很管用。
来看一个综合应用:遍历订单表,对金额大于1000的订单加“VIP”标记,小于等于的加“普通”标记,非法金额就跳过。
sql复制CREATE PROCEDURE sp_mark_order_level()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE o_id INT;
DECLARE o_amount DECIMAL(10,2);
DECLARE o_level VARCHAR(10);
DECLARE cur CURSOR FOR SELECT id, amount FROM orders;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO o_id, o_amount;
IF done THEN
LEAVE read_loop;
END IF;
IF o_amount < 0 THEN
ITERATE read_loop;
END IF;
IF o_amount > 1000 THEN
SET o_level = 'VIP';
ELSE
SET o_level = '普通';
END IF;
UPDATE orders SET level = o_level WHERE id = o_id;
END LOOP;
CLOSE cur;
END//
这个例子同时用到了游标、变量、异常处理(NOT FOUND handler)和循环控制,是一个很典型的综合场景。
不过我要指出一个很多人容易误解的点:CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; 这个写法里,done 会被重复赋值为 TRUE,但循环是靠 IF done THEN LEAVE 来退出的。这里的 CONTINUE 保证了 FETCH 到达末尾时不会抛异常,而是把 done 置成 TRUE,然后回到循环体里被检查到。理解了这个链路,游标遍历就没什么可怕的了。
4. 综合实战:用变量+异常+流程控制写一个订单汇总存储过程
前面知识点分开看都懂,但实际写的时候总会出各种问题。我在这一节给一个能直接跑起来的完整案例,从建表到最终验证,一步一步来。
4.1 准备表结构和测试数据
sql复制CREATE TABLE sales_order (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理, 1-已完成, 2-作废'
);
INSERT INTO sales_order(customer_id, amount, status) VALUES
(1, 1500.00, 0),
(1, 800.00, 1),
(2, 2300.00, 0),
(3, 500.00, 2),
(2, 1200.00, 1);
4.2 核心存储过程
需求是:按客户ID统计“已完成”订单总金额,如果该客户有作废订单,则把统计结果写到一张结果表,并在日志表里记录一条“含作废单”的提示,最终返回总金额。
sql复制DELIMITER //
CREATE PROCEDURE sp_sum_completed_amount(IN p_customer_id INT, OUT p_total DECIMAL(10,2))
BEGIN
DECLARE v_total DECIMAL(10,2) DEFAULT 0;
DECLARE v_cancel_count INT DEFAULT 0;
DECLARE v_done INT DEFAULT FALSE;
DECLARE v_amount DECIMAL(10,2);
DECLARE v_status TINYINT;
DECLARE cur CURSOR FOR
SELECT amount, status FROM sales_order WHERE customer_id = p_customer_id;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_total = -1;
END;
START TRANSACTION;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_amount, v_status;
IF v_done THEN
LEAVE read_loop;
END IF;
IF v_status = 1 THEN
SET v_total = v_total + v_amount;
ELSEIF v_status = 2 THEN
SET v_cancel_count = v_cancel_count + 1;
END IF;
END LOOP;
CLOSE cur;
INSERT INTO order_summary(customer_id, completed_amount, has_cancel, stat_time)
VALUES(p_customer_id, v_total, IF(v_cancel_count > 0, 'Y', 'N'), NOW());
COMMIT;
SET p_total = v_total;
END//
DELIMITER ;
这个存储过程里值得关注的点有三个。
一是事务和 EXIT HANDLER FOR SQLEXCEPTION 的结合。我在 handler 里执行 ROLLBACK,就是为了保证一旦中途出现异常,整个统计过程不会留下半截子数据。这个习惯强烈建议大家保持:存储过程涉及多步写入时,一定要在最外层包事务,并且在 EXIT handler 里回滚。
二是 COUNT 的替代方案。这里统计作废订单数用的是 SET v_cancel_count = v_cancel_count + 1,而不是先执行 SELECT COUNT(*) INTO ...。原因是刚才我提到过,SELECT INTO 查不到记录时变量不会变化,容易出问题;而循环内计数逻辑更可靠、也更直观。
三是局部变量的默认值。我用 DECLARE v_total DECIMAL(10,2) DEFAULT 0; 做了初始化,这样即使没有任何已完成订单,最终返回的也是 0 而不是 NULL。
4.3 执行与验证
调用这个存储过程并查看结果:
sql复制CALL sp_sum_completed_amount(2, @total);
SELECT @total;
预期 @total 应该是客户 2 已完成订单金额:1200.00。再看结果表,应该有对应记录,同时 has_cancel 字段为 N(因为客户2没有作废订单)。
如果你把 p_customer_id 改成 3,结果表会写入 completed_amount = 0,has_cancel = 'Y',因为客户3只有一笔作废订单。这就是自动化统计的意义:你不需要手动翻数据,跑一遍存储过程就全都汇总好了。
5. 高频错误自查表:变量、异常、流程控制的常见翻车点
这部分是压箱底的经验总结,我把这些年写存过时踩过的坑和帮同事排查的经典问题整理成一张速查表,遇到问题直接用。
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
DECLARE 位置报语法错误 |
DECLARE 没有放在 BEGIN 块开头 | 把变量声明集中到存储过程最前面 |
| 变量值为 NULL 导致计算结果为 NULL | SELECT INTO 未初始化 | 声明变量时加 DEFAULT 0 或初始化 |
| 游标循环提前退出或死循环 | NOT FOUND handler 忘记声明 | 声明 CONTINUE HANDLER FOR NOT FOUND,用 done 标志位控制退出 |
ELSEIF 报语法错误 |
写成了 ELSE IF |
改为连写 ELSEIF |
| CASE 全不匹配报错 | CASE 缺少 ELSE | 给 CASE 写兜底 ELSE |
| 存储过程执行后数据残留,回滚不干净 | 没在 EXIT handler 里写 ROLLBACK | 多语句写入存储过程统一包事务并回滚 |
| 变量在过程执行后还在 | 用了用户变量 @x |
若不需要跨会话共享,改用局部变量 |
| 整数相加结果异常 | INT 类型溢出 | 用 BIGINT 或 DECIMAL |
使用 + 拼接字符串 |
混淆了字符串拼接 | 改用 CONCAT() |
第二个值得单独强调的排查经验是:存储过程调试不像普通代码可以随手 print。想确认某个变量在某一时刻的值,我一般用两种方式:
- 临时建一张
debug_log表,在关键节点INSERT INTO debug_log(msg) VALUES(CONCAT('当前v_total=', v_total)); - 或者直接通过
SELECT输出(仅在调试阶段使用,生产环境要移除)
还有一次我遇到一个非常诡异的“SELECT INTO 匹配到多行”的错误,因为查询条件写得太宽,返回了两条记录,MySQL直接报 Result consisted of more than one row。对于这种情况,必须保证 SELECT INTO 的查询结果最多返回一行,实在不行就在 SQL 里加 LIMIT 1 兜底。
5.1 为什么变量初始化这么重要
我在前面反复强调初始化,是因为 MySQL 局部变量默认初始值是 NULL。在做加法、比较、拼接时,NULL 会像黑洞一样吞噬一切正常结果。最典型的翻车现场是:
sql复制DECLARE v_sum INT;
SET v_sum = v_sum + 100; -- 结果是 NULL
因为没有默认值,v_sum 初始为 NULL,加 100 后还是 NULL。这种问题在代码量大的存储过程里非常隐蔽,不打断点根本找不到。所以我的习惯是:任何变量声明都自带默认值,即使是零值也显式写出来。这让代码的意图更明确,也避免 NULL 污染。
5.2 游标与EXIT HANDLER的边界问题
最后说一个游标和异常处理结合的边界例子。如果你在存储过程里同时声明了 CONTINUE HANDLER FOR NOT FOUND 和 EXIT HANDLER FOR SQLEXCEPTION,这俩并不冲突。但要注意,NOT FOUND handler 在游标遍历结束时会触发,此时如果你在 handler 里还执行了其他可能触发 NOT FOUND 的语句(比如另一个 SELECT),就会陷入循环失控。我踩过一个很深的坑:在 NOT FOUND handler 里做 SELECT COUNT(*) INTO ...,结果这张表在某个瞬间是空的,COUNT 查询又触发了 NOT FOUND,然后又进入 handler,形成递归,最终把会话搞崩。
解决方案也很简单:NOT FOUND handler 里只做最简单的赋值和标记操作,不要执行可能返回空结果的 SELECT、不要执行 UPDATE/INSERT 等容易引发新异常的操作。如果确实要在异常触发时做额外处理,用多层嵌套的方式,把复杂的处理逻辑放到外层 BEGIN...END 块里,让内层 handler 只负责“记录状态+跳出”。
6. 几个高阶技巧,能用上的都算赚到
6.1 用标签块做模块化隔离
存储过程里也可以用带标签的 BEGIN...END 块,配合 LEAVE 实现“中途跳出”的逻辑。这个做法非常适合处理“前置校验不通过就提前返回”的流程:
sql复制main_block: BEGIN
DECLARE v_count INT;
SELECT COUNT(*) INTO v_count FROM orders WHERE order_id = p_id;
IF v_count = 0 THEN
LEAVE main_block;
END IF;
-- 继续执行后续逻辑
END main_block;
这段代码的核心含义是:如果前置校验失败,就不执行块内后续语句。这比用大量嵌套 IF 控制要清晰得多。
6.2 条件处理程序可以用别名常量
DECLARE ... HANDLER FOR 后面除了 SQLSTATE 还能声明自定义条件名:
sql复制DECLARE duplicate_key CONDITION FOR SQLSTATE '23000';
DECLARE EXIT HANDLER FOR duplicate_key BEGIN ... END;
这样代码的可读性会好很多。特别是多个 handler 都要针对同一类异常做处理时,条件名的复用价值很明显。
6.3 动态SQL中的变量传参
在存储过程里需要拼接 SQL 并执行时,变量传参要用 PREPARE + EXECUTE + USING:
sql复制SET @sql = CONCAT('SELECT * FROM ', table_name, ' WHERE id = ?');
PREPARE stmt FROM @sql;
SET @param = 100;
EXECUTE stmt USING @param;
DEALLOCATE PREPARE stmt;
不能直接把变量名嵌进字符串里让 MySQL 去解释。这个方法在写通用分页存储过程、按日期归档历史数据时非常实用,但要注意 PREPARE 语句只能使用用户变量,不能用局部变量。
7. 写在最后的一点经验
我个人在实际操作中的体会是:MySQL 存储过程的变量、中断处理、流程控制这三样东西,单拎出来每一个都不难,难的是把它们组合在一段真实业务逻辑里时如何保持代码的可读性和健壮性。
如果你现在才开始学存储过程,我建议不要一上来就追求炫技。先把局部变量、IF、WHILE、LOOP、游标、异常处理这些基础吃透,然后从“订单汇总”“报表统计”这类需求练手。写的过程中,把常见问题的自查表放在旁边,遇到奇怪的现象先对照一遍,能省很多排查时间。
最后再分享一个小技巧:存储过程写完之后,最好把所有变量初始化语句、游标声明、handler 声明都放在过程体最前面,并且用注释按“变量、游标、条件处理、业务逻辑”分块。这个习惯我坚持了好几年,好处是维护别人的过程时,一眼就能看出结构,不需要从头到尾读一遍才能定位变量在哪定义。好的存过,应该像一本结构清晰的书,而不是一本只有作者才读得懂的草稿。
