尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SQL UPDATE与DELETE高危操作:事务控制、索引优化与误恢复实战

SQL UPDATE与DELETE高危操作:事务控制、索引优化与误恢复实战 直接进入正题。写这篇文章的起因是前两天有个刚入行的同事在生产环境执行了一条 UPDATE忘了加 WHERE 条件整张表的字段全被刷成了同一个值。虽然他最后靠着备份恢复了数据但中间那几十分钟的慌乱应该能让他在未来很长一段时间里对 UPDATE 保持敬畏。说实话在 SQL 里UPDATE 和 DELETE 看起来就是几个关键字的区别但真正用好的关键在于对WHERE的理解、对事务的控制、对执行计划的认识以及对数据恢复策略的预判。本文就基于我这些年在实际项目中踩过的坑和使用经验把这两条语句掰开揉碎讲清楚。1. 内容整体设计与思路拆解为什么 UPDATE 和 DELETE 是“高危语句”1.1 风险核心权限大、影响面广、不可逆性突出在日常开发中SELECT 是读操作读错了无非是重新查一次代价很小。INSERT 是新增操作即使插错了也能比较容易地找到并清理掉。但 UPDATE 和 DELETE 不一样它们直接修改数据的存在状态。几乎所有事故都是因为没有充分审视 WHERE 条件的命中范围。我曾见过一个人写出的更新语句是UPDATE orders SET status PAID他以为只会影响某一条可这条语句没有任何限制结果就是把全部订单都置为已支付。这种问题在测试库中可能只是产生一条报错但在生产环境里往往就是重大事故。从设计角度看必须把 UPDATE 和 DELETE 视作“带着永久性破坏力的操作”。在逻辑上它们的核心工作路径可以拆成三步定位目标行这一步依赖 WHERE 条件如果没有WHERE目标就是全表。锁定并修改数据数据库引擎需要获取行锁或表锁然后修改对应数据页。记录日志与提交在事务模式下数据会先写入日志确保可以回滚或重放。所以理解 UPDATE 和 DELETE第一步不是理解语法本身而是理解**“定位”这一步**的优先级。任何一次执行前都应该先在脑子里问自己这条语句会把哪些行吃掉或改掉1.2 方案选型事务隔离级别与执行计划的两个维度在我的习惯里每次写 UPDATE 或 DELETE 前先确认两件事当前会话的事务隔离级别以及目标表上的索引情况。事务隔离级别决定了你在修改期间是否会被其他事务干扰。比如 MySQL 默认的REPEATABLE READ在更新时会使用当前读加锁读也就是说更新操作看到的是最新已提交的数据而如果用的是READ COMMITTED每次语句执行时都会重新获取快照意味着在批量更新时可能因为其他并发事务的提交而读到不一样的数据集。注意生产库中我强烈建议把 UPDATE、DELETE 这样的写操作放在显式事务中执行并设置合理的隔离级别比如READ COMMITTED。原因很简单UPDATE 和 DELETE 并不只是“改一条”那么简单它们牵涉到行锁、间隙锁、独占锁如果不控制好隔离级别和事务长度轻则产生死锁重则拖垮整个业务的并发能力。索引情况就更直接了。UPDATE的 WHERE 条件如果走的是主键索引通常能精确定位锁的粒度小如果走的是二级索引数据库需要回表拿主键再锁定对应的主键记录如果没有索引可用那就是全表扫描意味着锁范围会扩大甚至锁全表。所以一句话总结UPDATE 和 DELETE 的设计核心是先回答“我到底想动哪些行”再回答“怎么动最安全”。2. 核心细节解析与实操要点UPDATE 语法的进阶用法与参数选择2.1 基础语法里的“隐性坑”SET 子句的赋值顺序先看一个最简单的 UPDATE 语句UPDATE employees SET salary salary * 1.1, bonus salary * 0.2 WHERE employee_id 1001;注意这里隐含着一个很多新手都不知道的细节在 MySQL 中SET 子句的赋值是“从左到右”实时生效的。也就是说如果salary原本是 10000执行完第一段salary salary * 1.1后salary 先变成 11000。然后执行第二段bonus salary * 0.2这里的 salary 已经不是原来的 10000而是刚刚更新过的 11000所以 bonus 最终是 2200。这个行为在不同数据库里不一样。MySQL 的文档明确说明了这种“从左到右”的实时赋值行为。但在 SQL Server 中UPDATE 的 SET 子句通常使用的是“旧值”还是“新值”要取决于具体的执行计划并不保证有统一的规则。这是我踩过的比较深的坑之一原本以为 bonus 是按旧工资计算的结果一次性全部算错最后不得不写了一个反向补偿脚本。为了避免这种不确定性最稳妥的做法是在一个 UPDATE 语句里如果后续的字段值依赖前面某个字段的“新值”拆成多条独立的 UPDATE 语句或者使用子查询先计算好结果。例如上面的场景可以改成UPDATE employees SET salary salary * 1.1, bonus (salary * 1.1) * 0.2 WHERE employee_id 1001;如果你用的是 SQL Server建议写成一个明确计算后的派生值避免依赖默认的赋值顺序。当然MySQL 下其实上述两种写法结果基本一致但为了跨数据库兼容性和逻辑清晰我在项目规范里通常会建议大家不要在同一 SET 子句中依赖字段间的更新先后顺序。2.2 UPDATE 与 JOIN多表更新时如何精准控制范围现实业务中update 常用于根据另一张表的数据来更新当前表。比如电商场景里需要根据“退款表”的退款状态去更新“订单表”的订单状态。MySQL 支持多表更新语法UPDATE orders o INNER JOIN refunds r ON o.order_id r.order_id SET o.status REFUNDED, o.refund_time r.create_time WHERE r.status SUCCESS AND o.created_at 2024-01-01;这里最关键的是JOIN 的条件和 WHERE 的过滤条件共同决定了哪些行会被更新。在我的实践中最容易出问题的点是一对多关联导致同一行被更新多次。例如一个订单可能对应多条退款申请记录如果退款表里有两条 SUCCESS 记录上面的语句就会对同一订单更新两次。第二次更新时refund_time会被覆盖成后一条记录的时间。这其实不算错但如果你的场景里期望的是“最早退款时间”或“最晚退款时间”就必须先对退款表做聚合去重。推荐的做法是先构建子查询把多对一变成一对一再进行更新UPDATE orders o INNER JOIN ( SELECT order_id, MAX(create_time) AS refund_time FROM refunds WHERE status SUCCESS GROUP BY order_id ) r ON o.order_id r.order_id SET o.status REFUNDED, o.refund_time r.refund_time WHERE o.created_at 2024-01-01;另外在 SQL Server 中多表 UPDATE 使用的是UPDATE o SET ... FROM orders o INNER JOIN refunds r ON ...的语法。看起来很像 MySQL但要注意FROM关键字的位置在 SQL Server 中是写在 SET 之后的。如果团队里同时混用两种数据库这块需要格外小心很容易把 SQL Server 的语法直接复制到 MySQL 上或者反过来导致语法报错。2.3 UPDATE 的 LIMIT 限制批量更新的控制手段在 MySQL 中UPDATE 是支持LIMIT的这个语法很多开发者都不知道。例如UPDATE queue_table SET status PROCESSING WHERE status PENDING LIMIT 100;这条语句会从符合条件的数据里随机挑选 100 行进行更新实际上是遍历顺序上的前100条。这在做任务队列、消息状态抢占时非常有用可以避免一次性把大量数据锁住。但要注意一个比较隐蔽的问题LIMIT 和 ORDER BY 并不能真正保证“取最早的数据”。如果你希望总是优先处理最早入队的记录在 MySQL 里通常情况下应该加上ORDER BY比如UPDATE queue_table SET status PROCESSING WHERE status PENDING ORDER BY created_at ASC LIMIT 100;MySQL 是允许 UPDATE 后跟 ORDER BY 子句的。不过这里必须说明带 ORDER BY 的 UPDATE如果表数据量大可能会因为临时文件排序而变慢。我实际测试过百万级数据量下这种更新语句的耗时可能比不带 ORDER BY 的高出一个数量级。所以到底用不用 ORDER BY取决于你的业务优先级。如果只是简单地标记一批数据进入“处理中”状态不带 ORDER BY 的 LIMIT 更新更快如果你严格要求按时间顺序处理那么 ORDER BY 是必须的但请确保created_at上有索引。不建议在生产环境对千万级的大表直接执行带 ORDER BY 的 UPDATE否则会产生巨大的临时排序压力很有可能会拖垮实例。2.4 更新大表时的性能策略分批更新与索引利用分批更新是我在维护亿级数据表时最常用的方案。核心思想非常简单不要试图在一个 UPDATE 语句里修改几十万、上百万行而是把更新目标拆成小批次。例如我们在清理历史数据时会写一个存储过程或脚本循环执行UPDATE big_table SET is_deleted 1 WHERE created_at 2023-01-01 AND is_deleted 0 LIMIT 5000;然后通过程序或存储过程不停地调用直到影响行数为 0。这样每一次的锁范围很小不会长时间阻塞其他业务读写。同时由于每次只处理 5000 行binlog 和 redo log 的写入压力也被摊薄了。这里要补充一个关键参数如果使用的是 MySQL 的 InnoDB 引擎大批量 UPDATE 会导致 undo log 急剧膨胀因为每一行修改前都要记录旧值。曾经我在一个 5000 万行的表上执行一个超大 UPDATE结果 undo 表空间直接爆掉数据库实例 hang 住最后只能重启并进行事务回滚。回滚的过程又是几十分钟的煎熬。自那以后任何超过 1 万行的更新我都强制走分批方案。3. 实操过程与核心环节实现DELETE 的误区、TRUNCATE 对比与软删除设计3.1 DELETE 与 TRUNCATE看似相似本质大不同DELETE 和 TRUNCATE 都能清空数据但它们的行为差异直接关系到数据安全性和性能。先看一个对比表对比维度DELETETRUNCATE是否可带 WHERE可以按条件删除不可以只能全表清空是否记录行级日志记录每行删除都会写日志记录页级日志速度极快是否可回滚在事务内可以回滚InnoDB 下若在事务内可通过事务回滚但无法按行回滚是否重置自增 ID不重置MySQL 默认不重置重置为初始值MySQL 默认触发器会触发 DELETE 触发器不触发 DELETE 触发器锁粒度行锁或表锁取决于条件表锁主要锁整表我见过很多人在小表上使用 TRUNCATE感觉无所谓。但如果表上有外键引用TRUNCATE 会被拒绝执行因为 InnoDB 不允许在外键约束存在的情况下使用 TRUNCATE此时必须用 DELETE。这是第一个容易踩的坑。第二个容易踩的坑是磁盘空间释放问题。在 InnoDB 中DELETE 只是把数据标记为删除磁盘空间不一定立即归还给操作系统而是被标记为可复用。如果删除后不执行OPTIMIZE TABLE表文件在文件系统层面可能依然占据很大的空间。而 TRUNCATE 是直接重建表或快速释放页通常磁盘空间会立即可用。我遇到过很多次“误以为 DELETE 后空间释放了”的运维事故。一条 DELETE 删掉了几千万行看表空间还是 50G吓出一身冷汗后来才反应过来需要等后台 purge 线程清理或者手动执行OPTIMIZE TABLE tablename;才能真正回收空间。3.2 DELETE 的级联与依赖陷阱外键约束的两种表现DELETE 在涉及外键关联时处理不好会非常容易出错。如果表 A 被表 B 通过外键引用那么当你DELETE FROM A WHERE id1时数据库会有两个选择如果外键约束是ON DELETE CASCADE删除 A 表的记录时会自动删除 B 表中所有引用该记录的关联行。这个行为看似省事但我极端不建议在生产环境中随意依赖它因为它的副作用是隐形的。一旦误删了 A 表的一行B 表里可能悄无声息地少了十几行连个日志都不好追溯。如果外键约束是RESTRICT或NO ACTION那么当 B 表有引用时DELETE 会报错提示存在外键约束。针对外键的删除设计我比较推荐的做法是先查询关联数据量再决定删除策略。-- 先确认关联子表看有多少行会被连带影响 SELECT COUNT(*) FROM child_table WHERE parent_id 1001; -- 如果量小直接按顺序删除先子后父 DELETE FROM child_table WHERE parent_id 1001; DELETE FROM parent_table WHERE id 1001;之所以推荐“先子后父”是因为把删除顺序显式控制在自己手里而不是依赖数据库的级联行为。哪天业务逻辑调整了也不会被隐藏在数据库底层的级联规则坑到。3.3 逻辑删除与物理删除该 DELETE 时千万别手软在大多数业务系统里我强烈推荐使用逻辑删除软删除也就是加一个deleted_at字段而不是真正 DELETE 数据。原因很简单数据是公司的资产物理删除意味着彻底失去追溯能力。我在订单系统、财务系统、用户系统里几乎永远不直接 DELETE 主表数据而是统一通过UPDATE table SET deleted_at NOW() WHERE ...来标记删除。但逻辑删除也有一个代价每条涉及业务的查询都必须强制带上deleted_at IS NULL条件否则你做统计时会发现数据怎么都对不上。而且表里积累了大量已删除标记的数据会使索引体积变大查询性能下降。这时我们会定期把“已删除超过90天”的数据搬运到历史归档表再从主表中真删除。搬运过程用事务保证一致性START TRANSACTION; INSERT INTO orders_archive SELECT * FROM orders WHERE deleted_at IS NOT NULL AND deleted_at DATE_SUB(NOW(), INTERVAL 90 DAY); DELETE FROM orders WHERE deleted_at IS NOT NULL AND deleted_at DATE_SUB(NOW(), INTERVAL 90 DAY) LIMIT 5000; COMMIT;注意这里用的是 LIMIT 分批删除归档表一次插完没关系通常是把最终结果汇总出来再迁移但删除动作必须限流防止锁表。这个方案是当前很多中大型系统的标准玩法兼顾了数据可追溯和主表查询性能。3.4 删除超大表数据时的三个关键动作如果你确实确定要物理删除大量历史数据并不想用逻辑删除有几个动作必须做。第一基于主键范围分批删除而不是用 LIMIT。LIMIT 在没有 ORDER BY 的情况下MySQL 每次扫描的起点不固定效率会逐渐下降。更好的做法是DELETE FROM big_table WHERE id BETWEEN 1000000 AND 1005000;在一个循环里让 id 递增直到覆盖全部目标范围。因为主键是聚集索引范围扫描的速度是最快的。第二尽量在低峰期执行并且每次删除一批后COMMIT一次不要在一个事务里提交所有删除。不然 undo 日志会非常巨大严重时可能导致磁盘被写满。第三删除前确认 binlog 保留策略和备份策略。生产环境最好打开 binlog且在有完整备份的前提下做删除。哪怕是物理删除只要 binlog 存在就能通过时间点恢复。如果你连备份都没有那这一条 DELETE 就是生死局。4. 常见问题与排查技巧实录更新/删除语句引发的典型故障4.1 误更新、误删除后的应急恢复策略先说最坏的情况一条 UPDATE 或 DELETE 已经执行了数据被清掉了怎么办第一时间不要慌也不要再去执行新的写入操作。把当前库的会话状态冻结防止后续业务写入把旧数据覆盖得更彻底。如果数据库开启了 binlog且 binlog 格式是 ROWMySQL 8.0 默认那么恭喜恢复成功率很高。binlog 里记录着每一行变更的“前镜像”和“后镜像”可以进行反向解析。例如误删了数据可以从 binlog 中找出对应的 DELETE 事件提取出被删除行的完整数据binlog row 日志中包含每个被删除行的所有字段值然后重新 INSERT 回去。实际恢复步骤一般是找到误执行语句的大致时间点。用mysqlbinlog工具解析 binlog 文件。定位到 DELETE 事件或者 UPDATE 事件的旧值部分。把旧值转换成 INSERT 语句重新插入。一个更稳妥的方案是直接恢复到误操作前的某个时间点。用 MySQL 的binlog2sql这类开源工具可以反向生成 SQL 语句。在 MySQL 8.0 环境中还有官方的mysqlbinlog配合全量备份进行时间点恢复。我个人的建议是如果公司有条件定期做全量备份 binlog 增量备份。不要依赖“这次操作很小应该不会出错”的侥幸心理。宁愿每天备份多占一点磁盘也好过数据永久丢失后追悔莫及。4.2 SQL 注入视角UPDATE 和 DELETE 为何更致命聊到 SQL 注入大家通常把注意力放在 SELECT 上比如通过 OR 11 --绕过登录验证。但 UPDATE 和 DELETE 的注入一旦被利用危害是直接破坏性的。举个典型例子一个拼接 SQL 的删除操作String sql DELETE FROM articles WHERE id request.getParameter(id);攻击者传入参数1 OR 11最终执行的 SQL 就是DELETE FROM articles WHERE id 1 OR 11;这会直接把整张文章表清空。如果参数被传入1; DROP TABLE users; --就可能造成更严重的后果。所以在涉及 UPDATE 和 DELETE 的语句中必须强制使用参数化查询永远不要手工拼接用户输入。就算你在代码里做了敏感字符过滤也可能被绕过。参数化查询从根上切断了“用户输入被当成 SQL 代码解析”的可能。另外我建议在分配数据库账号时遵循最小权限原则业务查询账号只给 SELECT 权限后台管理账号才给 UPDATE、DELETE 权限。在实际项目里因为开发图方便用一个全权限账号跑所有业务导致注入漏洞被放大这种事故我见过不止一次。4.3 慢更新与慢删除从执行计划看真实瓶颈一条 UPDATE 或 DELETE 跑得很慢常见原因无非三类全表扫描、锁等待、大事务下的 undo 膨胀。以前我排查过一条慢 DELETE耗时超过 30 秒。执行计划显示没有可用索引需要全表扫描配合临时表过滤。解决方法是给 WHERE 条件字段建立合适索引后同样的 DELETE 语句耗时降到 50 毫秒。这里给一个非常有用的通用排查流程先用EXPLAIN SELECT ...看 WHERE 条件是否能命中索引因为 UPDATE 和 DELETE 的定位逻辑和 SELECT 基本一致。用SHOW PROCESSLIST查看有没有阻塞锁。用SELECT * FROM information_schema.innodb_trx;查看是否有未提交的长事务。如果是生产库切忌在业务高峰直接执行 DDL 建索引要根据负载情况选择低峰期。对于“慢”这个字我最深的体会是慢更新往往不是语句的问题而是表设计的问题。表上没有合适的索引再好的 SQL 也是白搭。4.4 SQL 去重中的 UPDATE 与 DELETE 实战在处理重复数据场景里UPDATE 和 DELETE 堪称主力工具。例如一张表里因为历史原因产生了重复的订单记录保留 id 最小的一条删除其余重复记录。我先写一个 SQL 语句DELETE t1 FROM orders t1 INNER JOIN orders t2 ON t1.order_no t2.order_no AND t1.id t2.id;这条语句利用自连接保留了每个 order_no 分组中 id 最小的那一条删掉了其他重复行。这种写法非常经典但有两个注意事项。第一执行前先 SELECT 确认影响范围SELECT t1.* FROM orders t1 INNER JOIN orders t2 ON t1.order_no t2.order_no AND t1.id t2.id;我几乎从不在执行 DELETE 前跳过这一步。别嫌麻烦多花三秒看结果能省掉几小时的回滚。第二如果表数据量很大自连接会导致临时表巨大。此时更稳妥的做法是先用 GROUP BY 把要保留的 id 查出来再分批 DELETEDELETE FROM orders WHERE id NOT IN ( SELECT id FROM ( SELECT MIN(id) AS id FROM orders GROUP BY order_no ) tmp ) LIMIT 5000;这里子查询多套了一层内联视图是为了绕过 MySQL “不能在同一张表中先 SELECT 再 UPDATE/DELETE”的限制。初次接触时可能会被这个限制困扰但其实在子查询外面再包一层派生表就能解决。5. 细节技巧与性能优化从执行计划到事务控制的全链路建议5.1 事务中的 UPDATE/DELETE锁与隔离级别的联动在我平时写代码的时候事务的粒度控制是重中之重。一个事务中UPDATE 和 DELETE 会持有锁直到事务提交或回滚。因此一个事务里最好不要包含过多行修改也不要混入耗时的远程调用。例如一个下单流程中先在事务里 UPDATE 库存表然后又去调用第三方支付接口等待响应此时数据库行锁会一直握在手里。如果第三方接口超时 5 秒这 5 秒内所有想改库存的请求都会阻塞。高并发下这直接表现为数据库连接池被打满。我的建议是事务内的 UPDATE/DELETE 只做纯数据库操作远程调用移到事务外面。一个事务里的影响行数尽量限制在千级以内。为重要的大更新操作设置事务超时时间innodb_lock_wait_timeout默认 50 秒太长生产环境我一般调到 10 秒以内让阻塞问题快速暴露。5.2 批量更新性能对比逐条 UPDATE 与 CASE WHEN 的取舍假设你要把一批用户的会员等级更新掉第一反应可能是用循环一条条执行 UPDATEUPDATE users SET level 1 WHERE user_id 1001; UPDATE users SET level 2 WHERE user_id 1002; UPDATE users SET level 3 WHERE user_id 1003;这种写法在数据量少时没有问题但只要超过几百条就会产生大量的网络往返和日志提交性能很差。MySQL 中可以用 CASE WHEN 拼成一条 UPDATE批量执行UPDATE users SET level CASE user_id WHEN 1001 THEN 1 WHEN 1002 THEN 2 WHEN 1003 THEN 3 END WHERE user_id IN (1001, 1002, 1003);我在 10 万级用户批量更新场景下实测过逐条 UPDATE 大约耗时 8 分钟CASE WHEN 批量更新耗时 2 秒左右差距非常可观。但这种写法也有局限如果更新的依据来自另一张表通常需要先 JOIN或者先聚合好数据再拼 SQL。同时要注意批量 UPDATE 时 CASE WHEN 如果命中很多行会导致一个事务变大所以要根据行数灵活调整一般单条语句控制在 1 万行以内。5.3 利用 UPDATE 和 DELETE 的返回行数来判断执行结果在写程序访问数据库时很多人会忽略 update/delete 的影响行数。影响行数是判断“操作是否真的符合预期”的最直接指标。举个例子取消订单操作UPDATE orders SET status CANCELLED WHERE order_id 123 AND status PAID;如果这个订单当前状态不是 PAID那影响行数就是 0说明订单已经处于其他状态不应该再执行后续的退款逻辑。在实际项目中我用这个特性避免了很多并发下的状态错乱。比如库存扣减UPDATE inventory SET stock stock - 1 WHERE sku_id 500 AND stock 1;如果影响行数为 1说明扣减成功如果为 0说明库存不足。在秒杀场景里这是最轻量级的防超卖方案之一。只要这条 UPDATE 的影响行数控制好再配合事务基本能保证库存不错乱。5.4 WHERE 条件的顺序与索引的关系严格来说MySQL 中的优化器会自动调整 WHERE 条件的判断顺序并不需要人工刻意把最优先的过滤条件放在最前面。但这里有一个容易造成误解的经验给 WHERE 条件中的列建立复合索引时要注意最左前缀原则。比如删除条件经常是user_id和status同时出现就可以建索引ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);这样 WHEREuser_id 1 ANDstatus CANCELLED 就能高效命中。如果你只单独查status这个复合索引用不上索引会失效。这是一个非常典型的细节问题很多时候慢 DELETE 不是因为删除本身慢而是 WHERE 条件根本走不了索引。6. 在一次误操作事故中我学到的最重要一课前面说了很多技术细节和语法案例最后我特别想跟大家分享一次真实的误操作经历。有一次晚上做数据订正要修改一批用户的手机号。我拿到一个去重后的用户名单想当然地写了一句UPDATE users SET phone 138xxxx8888 WHERE user_id IN (...);结果因为 IN 列表太长超过了程序拼接上限被截断成了一段残缺的 SQL。最可怕的是由于代码框架的限制截断后 WHERE 子句被拼成了WHERE user_id IN (1001, 1002, 1003缺少右括号。按常理应该直接报语法错误但那个框架在报错前尝试了自动补全括号最终执行出来的结果事与愿违直接更新了一大片用户数据。那次经历让我彻底明白了一个道理在 UPDATE/DELETE 面前再小的不确认也是大风险。后来我在团队里立了一条并不复杂的规矩凡是线上数据订正必须开着SELECT * FROM ... WHERE 完全相同的条件先看一遍结果集再用事务包裹好执行并在事务提交前确认影响行数。如果影响行数超过预期回滚。数据是无辜的绝大多数事故都源于我们的疏忽。写 UPDATE 和 DELETE 之前多看一眼 WHERE多跑一次 SELECT多确认一次备份这些习惯比任何炫技的 SQL 写法都重要。更新和删除是 SQL 里最普通不过的两条语句但越是普通越要用心。希望这篇文章能帮你少踩几个坑。
返回列表