1. 从“要不要用”到“怎么用好”:存储过程的价值与选型逻辑
我最早接触存储过程,是在某电商平台做订单系统重构那会儿。刚接手时,订单确认的链路很绕:下单要扣库存、要算优惠、要写流水、要更新会员积分,一套动作要连着执行好几条SQL,每条SQL还得分两次网络往返——先查再改,改完还得出错重试。后来老同事甩过来一句:“把逻辑塞进数据库里,写个存储过程,一条CALL调完。”我当时将信将疑,直到自己第一次把整套事务逻辑改写成存储过程,实测下来整个下单接口的响应时间从两三百毫秒降到了四五十毫秒,才真正意识到这东西有多香。
存储过程(Stored Procedure)说白了,就是把一串SQL语句打包成一个可重复调用的“方法”,存放在数据库服务器端。它在MySQL里不算是新功能,但是在实际项目里,用得好的人是真能提升效率,用得糙的人也是真能把自己坑死。这篇不打算照搬官方文档给你念参数列表,我想从实际写代码的角度,把存储过程的语法细节、参数设计、游标处理、性能调优和排错思路走一遍,踩过的坑一个不落。
1.1 为什么你觉得“不需要存储过程”?先重新判断一下
不少从ORM时代成长起来的开发同学,天然对存储过程有抵触感。这种抵触的逻辑一是“业务逻辑放在数据库里难维护”,二是“换数据库就得重写”。这两句话在十年前成立,但在今天不一定。
先说说为什么选择存储过程。核心原因其实就三点:
- 性能优势:减少SQL语句的网络传输和解析开销。多次交互变成一次调用,尤其适合批量数据处理和复杂事务。
- 事务集中管理:多条SQL的原子性可以在存储过程内部直接通过
START TRANSACTION/COMMIT/ROLLBACK控制,应用层不必反复协调事务边界。 - 权限控制更细:可以只给业务账号授予
EXECUTE权限,隐藏底层表的直接读写权限。
以我后来的几个项目看,最适合用存储过程的场景还是这几个方向:定时统计任务、复杂报表的数据准备、批量数据清理、多表联动的写入逻辑。反而是简单的单表CRUD,我不建议往存储过程里塞,那纯属自找麻烦。
1.2 我对大厂代码规范的观察:不是禁止,而是约束
你去一些技术社区翻博客,经常能看到“阿里的Java开发手册不让用存储过程”这类说法。我得说一句公道话:大厂不是不让用,是不让乱用。它们在数据库层面有专职的DBA管控变更,存储过程一旦上线,后续的版本管理和回归测试都比普通SQL复杂,所以默认不推荐。但在中小团队,数据库变更没那么频繁,业务节奏快,存储过程在一个清晰、克制的约定下反而能成为很趁手的工具。
我这里给的约定是三条:
- 只处理数据密集型逻辑,不承载业务状态流转;
- 存储过程只干和数据库强相关的事,业务规则留在应用层;
- 所有存储过程必须进版本库,用脚本文件管理,不允许直接在库上裸改。
2. 存储过程的基本语法与参数详解
2.1 一个最小的可运行示例
先不扯太远,直接看一段代码。我用最经典的订单金额统计来起步:
DROP PROCEDURE IF EXISTS get_order_summary; DELIMITER $$ CREATE PROCEDURE get_order_summary( IN p_start_date DATE, IN p_end_date DATE, OUT p_order_cnt INT, OUT p_total_amount DECIMAL(10,2) ) BEGIN SELECT COUNT(*), IFNULL(SUM(total_amount), 0) INTO p_order_cnt, p_total_amount FROM orders WHERE order_date BETWEEN p_start_date AND p_end_date; END$$ DELIMITER ;这段代码干了这么几件事:删掉可能存在的同名存储过程,自定义定界符DELIMITER,创建过程,最后恢复分号定界符。然后调用方式有两种:
-- 方式一:只关心执行,不关心输出 CALL get_order_summary('2024-01-01', '2024-01-31', @cnt, @amount); -- 方式二:查看输出参数的值 SELECT @cnt AS order_cnt, @amount AS total_amount;注意几个细节:
DELIMITER这一段是新手最容易卡住的地方。因为存储过程里的语句需要用分号结束,而MySQL客户端默认也把分号当成整体SQL的结束符。如果不用DELIMITER $$改掉客户端定界符,写一半报错是家常便饭。DROP PROCEDURE IF EXISTS这句话在脚本文件里几乎必备,否则重复执行时会出现“PROCEDURE already exists”错误。IFNULL(SUM(total_amount), 0)我这里是故意写的。SUM()在没有匹配行时返回NULL,不处理的话OUT参数会带着NULL回去,应用层稍不注意就出现空指针。
2.2 参数的三张王牌:IN / OUT / INOUT
MySQL存储过程的参数有三种模式,很多初学朋友容易搞混。
| 参数模式 | 作用 | 是否可在过程内修改 | 调用时需要传什么 |
|---|---|---|---|
IN | 输入参数,过程内部只读 | 可以读,但修改不影响外部 | 直接传值或变量 |
OUT | 输出参数,过程内部赋值 | 过程内可写,初始值为NULL | 必须传变量(加 @ 前缀) |
INOUT | 既是输入又是输出 | 可以读并修改,外部变量随之改变 | 必须传变量 |
理解IN和OUT的关键在于:“值传递”还是“引用传递”。IN是值传递,过程内改得翻天覆地也影响不到你外边传进来的变量;OUT是引用传递,过程内赋值结束后,调用方就能通过这个变量拿到结果;INOUT则是先传入一个初始值,过程内部处理后把新值传回来。
看一个实际例子,分页查询编号,既要从外部传一个种子值,又要拿回新的偏移量:
DELIMITER $$ CREATE PROCEDURE next_batch_ids( INOUT p_offset INT, IN p_batch_size INT ) BEGIN SELECT GROUP_CONCAT(id) INTO @ids FROM products LIMIT p_offset, p_batch_size; SET p_offset = p_offset + p_batch_size; END$$ DELIMITER ; SET @cur_offset = 0; CALL next_batch_ids(@cur_offset, 100); SELECT @cur_offset; -- 返回 1002.3 变量声明与作用域
存储过程的变量声明使用的关键字是DECLARE,而且格式上有顺序要求。一个常见的编译错误就是把DECLARE写在了执行语句后面,MySQL规定变量和游标声明必须在BEGIN块的开头部分,不然直接报ERROR 1337:
DECLARE v_cnt INT DEFAULT 0; DECLARE v_name VARCHAR(64) DEFAULT ''; DECLARE v_total DECIMAL(10,2) DEFAULT 0.0;变量名我习惯统一前缀,比如v_代表变量,p_代表参数,c_代表游标。这个习惯在过程体膨胀到几百行时极其救命。MySQL不像强类型语言有明确的编译期警告,一个变量名拼错了可能到运行期才暴露成NULL,那时候排错成本已经很高了。
2.4 逻辑控制结构:IF / CASE / LOOP / WHILE / REPEAT
MySQL存储过程支持的条件控制和循环控制,语法上跟其他数据库稍有差异。
条件分支:
IF p_status = 'A' THEN SET v_desc = '已激活'; ELSEIF p_status = 'I' THEN SET v_desc = '未激活'; ELSE SET v_desc = '未知状态'; END IF;循环结构里我实际用得最多的是WHILE和REPEAT,区别在于先判断后执行还是先执行后判断。有一点注意,MySQL的循环不支持FOR,别写习惯了FOR i IN ...` 卡在这里:
WHILE v_i <= p_cnt DO SET v_i = v_i + 1; -- 业务处理 END WHILE;LEAVE和ITERATE相当于其他语言的break和continue。用LEAVE提前退出循环时,需要在循环前面加一个标签名:
batch_loop: WHILE v_i <= p_cnt DO IF v_skip_flag = 1 THEN SET v_i = v_i + 1; ITERATE batch_loop; END IF; IF v_done = 1 THEN LEAVE batch_loop; END IF; END WHILE;我从实战中得到的经验是,能用WHILE ... DO的尽量不用复杂的标签循环,道理很朴素:标签嵌套最好只出现在单个过程内不超过两层,三层以上的嵌套实际维护起来真的是噩梦。
3. 核心实现拆解:事务、游标与动态SQL
3.1 在存储过程中正确使用事务
存储过程最大的卖点之一,就是在数据库端完成事务控制。但与直接用SQL不同,过程中要自行注意事务提交时机。
举个库存扣减的例子。一边要插入订单明细,一边要扣减库存,还要写流水表。这三步必须放在一个事务里,不然就会出现“订单建了,库存没扣”这种要命的bug:
DELIMITER $$ CREATE PROCEDURE create_order_flow( IN p_user_id INT, IN p_sku_id INT, IN p_quantity INT ) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; INSERT INTO orders(user_id, sku_id, quantity, create_time) VALUES (p_user_id, p_sku_id, p_quantity, NOW()); UPDATE inventory SET stock = stock - p_quantity, update_time = NOW() WHERE sku_id = p_sku_id; INSERT INTO inventory_flow_log(sku_id, change_qty, flow_type, create_time) VALUES (p_sku_id, -p_quantity, 'ORDER', NOW()); COMMIT; END$$ DELIMITER ;重点说两个容易写错的地方:
DECLARE EXIT HANDLER FOR SQLEXCEPTION是异常处理的关键。这里EXIT HANDLER的意思是当遇到SQL异常时,执行大括号里的语句(回滚)然后退出过程。如果不加这个handler,默认情况下MySQL遇到SQL错误会直接终止过程并回滚当前事务,但实际上你没法控制回滚前的收尾动作,所以还是显式声明最踏实。RESIGNAL的作用是把异常信息重新抛给调用方。我见过很多存储过程catch住异常后在过程内偷偷吞掉,应用层没有任何感知,后续排查问题像大海捞针。我的习惯是:存储过程要处理异常,更要“大声”抛异常。
3.2 游标实战:逐行处理不是洪水猛兽,但也不能乱用
游标在存储过程里是个很有争议的话题。有些人极其排斥游标,觉得“能不用尽量不用”,因为单条处理性能差。这个判断是对的,在数据量几百万表上跑逐行游标,那确实是在挑战数据库的极限。但换个场景——数据量不大、逻辑确实需要逐行判断,比如对异常账务数据逐条进行规则补录,游标反而是最直观的实现方式。
游标的使用流程固定是四步:声明、打开、取数、关闭。中间还要配合一个DECLARE CONTINUE HANDLER FOR NOT FOUND来标记取数结束。
DELIMITER $$ CREATE PROCEDURE handle_abnormal_orders() BEGIN DECLARE v_order_id INT; DECLARE v_status VARCHAR(8); DECLARE v_done INT DEFAULT 0; DECLARE cur CURSOR FOR SELECT order_id, status FROM orders WHERE status = 'ABNORMAL'; DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; OPEN cur; process_loop: LOOP FETCH cur INTO v_order_id, v_status; IF v_done = 1 THEN LEAVE process_loop; END IF; UPDATE order_retry_log SET retry_count = retry_count + 1, last_retry_time = NOW() WHERE order_id = v_order_id; UPDATE orders SET status = 'PENDING' WHERE order_id = v_order_id; END LOOP; CLOSE cur; END$$ DELIMITER ;这里三个关键点:
DECLARE CONTINUE HANDLER FOR NOT FOUND必须放在游标声明之后。它的作用是当FETCH找不到更多数据时,把v_done置为1,让循环有退出的依据。如果漏了这段,循环会报ERROR 1329。- 游标要先打开,再循环,最后关闭。
CLOSE cur这步很容易忘,虽然过程结束时会自动释放,但长期跑大量过程还是有可能累积资源占用。 - 不要在循环体内执行大事务。如果你想在循环里逐条更新字段并让这个过程中断时整体回滚,那就必须在游标循环外再包一个事务控制,否则每条更新是自动提交的,回滚根本救不了已提交的数据。
我自己踩过的一个坑:有一个对账清洗脚本,外面忘了统一事务控制,处理到第1万条时发现规则写错了,改完规则再跑一遍,前几千条已经落库的脏数据还得写额外的SQL自己修。从那以后,凡是带游标的过程,我第一件事就是确认事务边界在哪。
3.3 动态SQL的做法与安全红线
存储过程里有时候SQL的条件无法在编译期内确定,比如表名是参数传进来的,或者排序字段是动态的。这时候要拼SQL并交给客户端执行,用的是PREPARE/EXECUTE/DEALLOCATE PREPARE三件套:
DELIMITER $$ CREATE PROCEDURE p_query_by_condition( IN p_table_name VARCHAR(64), IN p_condition VARCHAR(255) ) BEGIN SET @sql_text = CONCAT( 'SELECT * FROM ', p_table_name, ' WHERE ', p_condition ); PREPARE stmt FROM @sql_text; EXECUTE stmt; DEALLOCATE PREPARE stmt; END$$ DELIMITER ;但必须郑重提醒,动态SQL最大的风险就是SQL注入。表名、字段名这类标识符不能简单地用参数占位符替代,只能拼接;但拼接的前提是你要对传入的值严加校验。我的做法是设计一个白名单,只有白名单内的表名和字段名才允许进入拼接逻辑,除此之外一律拒绝执行。
IF p_table_name NOT IN ('orders', 'order_items', 'products') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid table name'; END IF;这个SIGNAL关键字就是存储过程里主动抛业务异常的工具。SQLSTATE '45000'是通用的“用户自定义异常”状态码,抛出的异常能被应用层捕获到。
4. 调试手段与常见问题排查实录
存储过程不好调试是出了名的,不像普通代码可以设断点。但也不是完全没法调,我分享一遍自己平时排查问题的完整思路。
4.1 把调试信息“打印”出来
MySQL没有像 SQL Server 那样可以直接PRINT到消息窗口的机制,常见的做法是把中间结果塞进临时表、用户变量或者日志表。
最简单的输出方式:
SELECT v_done, v_order_id, v_status;这个查询在存储过程执行时会把结果作为结果集返回给调用方。如果你用的是命令行客户端,直接能看到。如果你用的是图形界面工具,也可以在结果集窗口看到。但注意,在循环里不要用SELECT每次打印,那会刷屏。
更好的方式是建一张调试日志表:
CREATE TABLE proc_debug_log ( id INT AUTO_INCREMENT PRIMARY KEY, proc_name VARCHAR(64), log_level VARCHAR(16), log_msg VARCHAR(512), create_time DATETIME ); -- 在过程中插入一行调试信息 INSERT INTO proc_debug_log(proc_name, log_level, log_msg, create_time) VALUES ('handle_abnormal_orders', 'INFO', CONCAT('current v_order_id=', v_order_id), NOW());这个方法虽然多了一些写日志的开销,但在排查复杂循环逻辑时,比任何“盲猜”都高效。我曾经靠这种方法,在某个统计存储过程里定位到了一个GROUP BY语句里面字段写错的低级问题——直接用SELECT看是看不出毛病的,但日志表里每跑一步的中间值对不上,一下就看出来了。
4.2 常见报错速查表
| 报错信息 | 含义 | 排查方向 |
|---|---|---|
ERROR 1064 | 语法错误 | 检查DELIMITER是否使用正确,括号是否配对 |
ERROR 1337 | 变量/游标声明顺序问题 | DECLARE必须在BEGIN块内最前面 |
ERROR 1329 | 游标取数超过末尾 | 检查是否声明了NOT FOUNDhandler |
ERROR 1415 | 过程返回了结果集,传入只允许标的上下文 | 检查是否在大事务里意外执行了SELECT |
ERROR 1318 | CALL参数个数或类型不匹配 | 核对调用时的参数个数和类型 |
ERROR 1364 | 非空字段未提供默认值 | 检查插入语句是否漏了必填字段 |
ERROR 1055 | ONLY_FULL_GROUP_BY模式下的分组问题 | 检查SQL_MODE和GROUP BY字段完整性 |
4.3 几个真金白银的经验教训
过程体里的临时表需要小心处理。如果把事务提交写在了创建临时表之后,提交时临时表不会被释放,但存储过程退出时会自动丢弃,这个设计本身挺好。然而有一种情况很令人困扰:在存储过程内部创建临时表,调用后发现会话里的临时表还在,因为临时表是会话级别的,只有关闭连接才彻底清除。遇到这种情况,我在过程末尾会加一句DROP TEMPORARY TABLE IF EXISTS tmp_xxx,省得下次调用出幺蛾子。
还有一个老生常谈但确实高发的坑:字符集不一致导致存储过程比较结果异常。两个表字段一个是utf8mb4一个是latin1,直接JOIN倒不太报错,但如果存过程里拼接字符串后用IN或者LIKE比较,很容易出现“明明看着一样,就是匹配不上”的诡异问题。排查时第一件事就是检查字段的collation_connection和字符集。
死锁问题在存储过程里也常见。多个存储过程同时被调用时,如果都按同一顺序更新多张表,倒没大碍;但如果顺序不一致,就容易互相等待形成死锁。我经历的“经典案例”是:库存扣减过程先更新orders表再更新inventory表,另一个促销结算过程先更新inventory表再更新orders表,并发一上去,死锁率直线飙升。最后两个过程统一改成先更新inventory再更新orders,问题直接消失。在存储过程的开发规范里,锁顺序必须全局统一。
慢查询的排查逻辑也有别于普通SQL。存储过程内部消耗时间发生在数据库端,慢查询日志里能看到调用它的SQL,但具体慢在内部哪一步,看不到。我的做法是:给每个关键操作前后都加上执行时间记录,比如在过程里定义变量记录NOW(),或者把耗时写入日志表。不然你只能干瞪眼。
5. 性能调优与维护规范
5.1 避免的“坏味道”
存储过程写久了,你会慢慢形成一种“这里闻起来不对”的敏感度。我整理了一份自己的反面清单:
- 在循环里逐条执行同一条INSERT。能改集合操作就改集合操作,这是对性能最大的尊重。
- 在过程内频繁
SET SESSION改系统变量。一个存储过程执行完不恢复,影响会波及整个连接。 - 使用动态SQL拼条件但缺少白名单校验。
- 过程体超过1000行。分拆成多个过程,用过程调用过程,结构清晰得多。
- 用存储过程去实现纯展示逻辑。表示层的字段格式化、数据排序规则,不该放在数据库层。
5.2 参数化条件与索引配合问题
存储过程里难搞的一个问题是参数化查询条件下,优化器对索引的选择可能变得很“迷”。比如:
SELECT * FROM orders WHERE order_date BETWEEN p_start_date AND p_end_date AND status = p_status;如果p_status为空字符串时代表不过滤,新手容易写出AND (p_status = '' OR status = p_status)。这种写法在SQL层面是可以的,但可能造成优化器没办法稳定走索引。更好的做法是用IF分支分开执行两条SQL:
IF p_status = '' THEN SELECT * FROM orders WHERE order_date BETWEEN p_start_date AND p_end_date; ELSE SELECT * FROM orders WHERE order_date BETWEEN p_start_date AND p_end_date AND status = p_status; END IF;虽然过程代码多了几行,但对索引的友好度完全不同。
5.3 版本管理:把存储过程纳入Git
说到存储过程的维护,绝大多数中小团队都是直接在库管理工具里改,时间一长全是历史遗留账。我的习惯是,每个存储过程一个独立SQL文件,文件名用编号前缀和用途命名,放到Git仓库的db/procedures/目录下。每次改动走联调、评审、执行三步。
文件头会写上变更记录:
-- procedure: get_order_summary -- author: mosky -- date: 2024-06-01 -- version: 1.2 -- changes: v1.2 add p_start_date/p_end_date parameter5.4 还有一件事:权限管理
存储过程创建后,默认只有创建者能执行。在实际生产环境里,我建议单独建一个只拥有SELECT, INSERT, UPDATE, DELETE的业务账号,并授予它相关存储过程的EXECUTE权限,而不给底层表的权限。这样应用层账号即使是被人拖库了,也只能调用过程,拿不到最底层的全量数据。
CREATE USER 'order_app'@'%' IDENTIFIED BY 'strong_password'; GRANT EXECUTE ON PROCEDURE mydb.get_order_summary TO 'order_app'@'%'; GRANT EXECUTE ON PROCEDURE mydb.create_order_flow TO 'order_app'@'%';有一点容易忽略:存储过程内如果访问了表,账号除了过程的EXECUTE权限外,还必须拥有这些表的相应权限,否则过程会在运行时因为权限不足而失败。这个在很多生产事故里都真实发生过,一定要给账号同时授予表权限。
6. 我的一次完整实战复盘
最后我想用一个我自己完整走过的项目来收尾。某积分商城项目需要每天凌晨跑一次用户积分结息,逻辑是:遍历所有积分账户,按各项来源计算当日积分变动,更新账户表并写入积分流水表。数据量大约80万账户。最初用应用层脚本做,每晚耗时在40分钟以上,后来决定迁移到存储过程。
整体设计是这样的:
DELIMITER $$ CREATE PROCEDURE daily_interest_settlement( IN p_settle_date DATE ) BEGIN DECLARE v_user_id INT; DECLARE v_balance DECIMAL(12,2); DECLARE v_interest DECIMAL(12,2); DECLARE v_done INT DEFAULT 0; DECLARE cur_account CURSOR FOR SELECT user_id, balance FROM user_points_account WHERE is_active = 1 FOR UPDATE; -- 加锁避免并发修改 DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; START TRANSACTION; OPEN cur_account; account_loop: LOOP FETCH cur_account INTO v_user_id, v_balance; IF v_done = 1 THEN LEAVE account_loop; END IF; SET v_interest = ROUND(v_balance * 0.0001, 2); IF v_interest > 0 THEN UPDATE user_points_account SET balance = balance + v_interest, update_time = NOW() WHERE user_id = v_user_id; INSERT INTO points_flow_log(user_id, change_type, change_amount, create_time) VALUES (v_user_id, 'INTEREST', v_interest, p_settle_date); END IF; END LOOP; CLOSE cur_account; COMMIT; END$$ DELIMITER ;这里刻意用了游标逐行处理,事实上80万账户逐行跑确实比集合更新慢不少。但当时的业务规则里利息计算依赖账户的余额快照,且有复杂的四舍五入策略,逐行处理反而最容易控制正确性。最终迁移后整跑时间大约8分钟,性能提升了5倍,而且由于不存在应用层和数据库的两地传输,出问题的概率也大幅下降。
复盘下来我自己的体会是:
- 游标不是不能用,但用之前要明确它换来的是逻辑清晰度。
- 大事务和长事务要警惕。这个过程包了80万行的UPDATE和INSERT,如果中途失败回滚,代价不小,所以必须加异常处理并在关键节点写日志。
- 存储过程的“性能问题”大多不在过程本身,在你写的SQL和索引设计。把过程烤成黑盒没问题,但把过程写成不合理的游标循环,那谁也救不了。
7. 存储过程之外:如何优雅地持续改进
写存储过程就像写普通代码,写完不代表完事,还需要持续演进。这里有三点我长期坚持的操作建议。
7.1 建立存储过程的回归验证脚本
存储过程最怕改一处崩三处。我在项目里会维护一套回归SQL脚本,里面包含了过程中涉及的表结构的假设、参数边界值、预期输出结果。每次改动后跑一遍,基本能拦住大多数低级问题。边界值尤其重要,比如空表、超大日期范围、负金额、NULL输入,这些在真实调用里一定会遇到。
7.2 保持与应用层的契约稳定
存储过程的入参和出参尽量稳定,不要频繁变更。如果确实要改,要做好调用方的同步发布顺序。我见过一次上线事故:存储过程多增加了一个OUT参数,但应用层的调用代码还是只传两个变量,MySQL报参数数量错误,接口大面积失败。这个教训给我的启示是:存储过程修改要当作一次接口变更管理,而不只是数据库脚本调整。
7.3 引入代码审查机制
团队的存储过程也应当像应用代码一样做Commit审查。审查重点集中在:事务边界是否合理、游标是否必要、动态SQL是否安全、是否存在不必要的大事务、字符集是否一致。过程类代码审查比应用代码审查更依赖经验,毕竟MySQL的执行计划不像Java那样能在IDE里直接看到。
我自己见过太多“能用但很脆”的存储过程,靠的就是一轮轮代码审查把它们拦在上线之前。哪怕是单机内部系统,也应该把这个流程走起来,越早走越省钱。
存储过程这个领域,真把语法啃完只需要一两天,但把“什么时候该写、怎么写不坑、改完怎么验证”这套经验跑通,至少得经历两三个真实项目的打磨。这篇文章给的规范和方法,是我一路踩坑踩出来的积累。如果你能按这些原则落地,即使一开始慢一点,后面一定比我少走很多弯路。