ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP幂等消费最优解:消息队列重复消息的全面防御

PHP幂等消费最优解:消息队列重复消息的全面防御 2. 幂等消费为什么是你绕不开的坎做PHP后端的朋友尤其是负责支付回调、消息队列、定时任务这类场景的肯定遇到过这样一个问题——同一笔订单的webhook通知一连来了好几次或者MQ里同一条消息被重新投递。这时候如果代码里没做防护用户的余额可能被扣了两次积分可能到账了两倍订单状态可能被覆盖成错误的结果。这个问题的根子就是“非幂等”同一个操作执行一次和执行N次结果不一样。幂等消费说白了就是让消费端处理同一条消息时无论来多少遍最终效果都和只处理一遍相同。这听起来简单但实际落地时坑非常多消息中间件的“至少一次at least once”投递语义决定了重复是常态消费端重启、网络超时、事务回滚也会造成重复最要命的是这些重复往往不声不响直到对账那天才发现数据已经乱成一锅粥。这篇文章我从自己的实际项目经验出发把幂等消费的几种主流思路、PHP落地代码、还有那些文档里不会写的坑一次性讲清楚。如果你正在写RabbitMQ消费者、订单支付回调、第三方接口轮询或者准备设计一套能扛住乱序和重复消息的处理逻辑这篇应该能帮你省下不少调试时间。2.1 重复消息到底从哪里来先搞清楚“重复”是怎么产生的才不会在设计方案时漏环节。一是消息队列的投递语义。RabbitMQ、Kafka、RocketMQ这些中间件默认或常见配置下都只保证“至少一次”投递。消费者处理完后还没来得及确认ack进程突然挂了这条消息就会重新进入待消费队列。二是消费者自身的网络超时。很多PHP框架里你调用MQ客户端的ack方法时网络闪断服务端没收到确认重试机制自然会把消息再发一遍。三是回调类接口的调用方策略比如支付平台的通知往往会按间隔重试很多次直到你返回成功标识为止。所以说重复不是巧合而是系统里客观存在的预期行为。我们做设计的默认前提就应该是“消息一定会重复”在这个前提下去做幂等而不是心存侥幸觉得“我们的消息量少应该没事”。量少只是降低了出问题的概率一旦出问题涉及到的往往是钱、库存、积分这类最敏感的数据。2.2 不处理幂等的真实后果光说“可能有问题”不够具体我举个真实的例子。之前我维护过一个小型电商系统对接了某支付平台的异步通知。开发时只校验了签名没有做幂等结果上线第二天就有用户反馈付款成功但订单变成了“待支付”状态后台一查支付成功回调来了两次第一次把订单改成已支付第二次因为数据库里某个状态字段已经被改掉逻辑走了异常分支反而把状态回退了。另一个例子更隐蔽。积分系统消费MQ里的“订单完成”事件给用户加积分。消费逻辑很简单加完积分就ack。但某天凌晨MQ服务端发生故障一批消息重新入队第二天上午用户积分普遍翻倍后台工作人员手动调了半天数据才补救回来。这两个例子只是想说明幂等不是空谈设计它直接决定你的数据正确性和半夜要不要爬起来救火。3. 四种主流幂等方案选型幂等方案没有银弹不同场景要选不同类型的方案。我常用的是四类各有优劣下面逐一拆解。3.1 数据库唯一索引去重这是最经典、也是门槛最低的幂等方案。核心思路是在业务表里建一个唯一索引字段这个字段的值就是这次操作的业务唯一ID。每次消费消息时先尝试往一张专门去重的流水表或订单表本身的业务编号字段插入一条带有这个唯一ID的记录。如果插入成功说明这条消息是第一次消费可以继续执行后续逻辑如果插入时报“重复键”错误说明已经处理过直接忽略。这个方案的精髓在于“数据库帮你做了原子判断”——不需要加分布式锁不需要查了再判插入操作本身就是原子的。并发环境下两个进程同时插入同一个唯一ID只有一个能成功另一个必然撞唯一索引。这比“先查再插”那种非原子操作靠谱得多。我用伪代码展示一下核心逻辑try { $pdo-beginTransaction(); // 关键一步插入去重记录利用唯一索引拦截重复 $pdo-exec(INSERT INTO consume_log (biz_id, handle_time) VALUES ({$bizId}, NOW())); // 走到这里说明是首次消费继续业务逻辑 $order $orderService-findByOrderNo($orderNo); $order-setStatus(Order::STATUS_PAID); $orderService-update($order); $pdo-commit(); } catch (PDOException $e) { // 23000是SQLSTATE里的唯一约束冲突 if ($e-getCode() 23000) { // 重复消息不需要处理 $pdo-rollBack(); return; } throw $e; }有些朋友会问直接往业务表里插不单独建流水表行不行可以前提是你业务表里正好有那个天然的、不会被业务修改的唯一业务号比如订单号、支付流水号。但有个实际问题业务表条件复杂比如积分类场景你要往明细表插入一条记录这条记录本身就是业务数据这时把“消费标识”直接做成明细表的唯一键其实是最省事的。需要单独设计流水表时注意流水表要考虑数据量增长建表时按业务量预估好分表和归档策略。3.2 Redis原子操作与分布式锁如果系统的并发量更大或者不想给数据库增加多余写入Redis是不错的选择。常见做法有两种。第一种利用SET NX EX命令做幂等标记。这个命令只有键不存在时才会设置成功并且可以指定过期时间。消费前先尝试设置一个“消费中/已消费”标记设置成功才能执行业务逻辑。这个标记就相当于一个分布式锁/幂等锁在锁的有效期内重复消息都会被挡住。$redis new Redis(); $redis-connect(127.0.0.1, 6379); $lockKey consume_lock: . $bizId; // 只有key不存在时才能设置成功相当于抢锁 $result $redis-set($lockKey, 1, [nx, ex 10]); if (!$result) { // 说明已有其他进程在处理这条消息或者已经处理过了 return; } try { // 执行业务逻辑 $this-doBiz($bizId); } finally { // 处理完后释放锁。注意不要删掉别的进程的锁 // 建议用Lua脚本比较value再删除 }第二种用Redis的incr或setnx配合过期时间实现“幂等标记”。如果标记值自增后等于1说明第一次处理否则是重复。原理和第一种类似看个人习惯。Redis方案的优点是快能扛高并发减轻数据库压力缺点是依赖Redis本身的可靠性一旦Redis崩溃或键提前过期会出现误判。所以正经项目中我一般不会只用Redis而是让Redis承担大部分拦截工作数据库唯一索引在底层兜底。3.3 状态机流转兜底有些业务本身带有明确的状态字段天然能做幂等。比如订单状态机待支付 - 已支付 - 已发货 - 已完成。如果消费的消息是“支付成功”处理逻辑应该是“把待支付改成已支付”。这时加一个前置判断就够了只有当当前订单状态是待支付时才允许改成已支付如果已经是已支付说明这条消息要么是重复的要么是乱序到达的直接忽略。$order $orderService-findByOrderNo($orderNo); if ($order-getStatus() ! Order::STATUS_PENDING_PAYMENT) { // 非待支付状态说明消息重复或乱序直接丢弃 return; } $order-setStatus(Order::STATUS_PAID); $orderService-update($order);很多团队都把这种状态判断当作“最后的防火墙”不需要额外建表也不需要额外Redis锁代码理解成本低。但它不能覆盖所有场景——比如“给用户加积分”或者“发短信”这类没有状态概念的独立操作就不能靠状态机来保证幂等。所以我的习惯是能靠状态机判断的场景先用状态机挡一层状态机覆盖不了的再用流水表或Redis锁。而且状态机判断本身要和数据库更新放同一个事务里否则并发下会出现两个请求都读到待支付状态、后一个把前一个覆盖的情况。3.4 本地消息表与事务消息如果你们团队用的是RabbitMQ并且极度重视“消息绝对不丢、消费绝对幂等”可以搭一套本地消息表方案。思路是把消息发送和业务操作放在同一个数据库事务里业务表更新成功的同时往本地消息表写一条“待发送”记录。然后由一个独立的定时任务扫这张表把未发送的消息投递到MQ。消费端消费成功后再把这个消息表的标记更新为“已发送”。这套方案更多解决的是“发送端不丢消息”对消费端的幂等并没有直接作用但因为它自带了消息唯一ID、发送状态这些元数据给消费端幂等提供了很方便的基础。如果你的项目已经用了事务消息消费端幂等可以直接利用消息ID本身来做去重。与之配套的还有“消息幂等表”这张表专门记录“哪些消息ID已经被消费过了”消费前先插入一条消息ID插入成功才执行业务。本质上是数据库唯一索引去重的变种只是把业务唯一键换成了消息ID。这个方案通用性很强几乎可以套在任何消息系统上。4. PHP落地实现从零搭一个幂等消费框架思路清楚了我们直接落一套能在PHP项目里使用的幂等消费框架。这里假设你在用RabbitMQ数据库是MySQL缓存是Redis。如果你用的不是这套组合思路同样可以平移。4.1 幂等键的生成规则幂等方案的第一件事是定义好“幂等键”biz_id。这个键是判断重复的核心依据你得回答一个问题这条消息的业务唯一标识是什么常见的选择有订单号适合订单状态变更类消息。支付流水号适合支付回调类消息一个流水号理论上只会对应一次支付。消息ID 消息类型适合MQ通用场景用发布者生成的消息ID加操作类型拼出唯一键。用户ID 操作场景 时间戳适合积分、红包这类按用户维度判断的独立操作。生成规则只有一条硬性标准在业务域内同样的重复消息必须生成完全一致的幂等键而不同的业务操作必须生成不一致的幂等键。如果这个键在某些情况下变了幂等就被破功了。举个反例如果用当前时间戳生成幂等键那么同一条消息重试时时间戳变了幂等键就会变方案直接失效。所以在设计时一定要用业务本身提供的、稳定的唯一标识而不是消费端临时生成的东西。4.2 基于Redis的消费前拦截进到完整实现第一步是消费前的Redis拦截层。这里我倾向于用“加锁 Lua脚本释放”的组合避免误删锁。class IdempotentConsumer { private $redis; private $lockPrefix idem_lock:; private $lockExpire 15; // 秒 public function __construct(Redis $redis) { $this-redis $redis; } /** * 尝试获取幂等锁 * return bool true可以消费, false重复消息或正在被处理 */ public function tryLock(string $bizId): bool { $key $this-lockPrefix . $bizId; // NX只在键不存在时写入EX过期时间 return (bool)$this-redis-set($key, 1, [nx, ex $this-lockExpire]); } /** * 释放锁使用Lua脚本保证原子性 */ public function unlock(string $bizId) { $script LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end LUA; $this-redis-eval($script, [$this-lockPrefix . $bizId, 1], 1); } }这里有个非常重要的细节tryLock和业务执行之间不是原子性的。如果业务执行时间超过了锁的过期时间锁会自动失效另一个消费进程就会趁虚而入导致同一笔业务被并发执行两次。所以在设计业务时要评估好单条消息的最大处理时间锁的过期时间必须大于这个时间并且保留充足的余量。我通常设置成平均处理耗时的10倍以上至少15秒起步。4.3 数据库唯一索引兜底Redis锁最怕过期时间设置不合理所以生产环境必须加数据库层的兜底。我实践下来的做法是新建一张独立的幂等消费记录表CREATE TABLE idempotent_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, biz_id VARCHAR(128) NOT NULL COMMENT 幂等键, biz_type VARCHAR(64) NOT NULL COMMENT 业务类型, status TINYINT NOT NULL DEFAULT 1 COMMENT 1处理中 2处理完成, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT幂等消费记录表;这张表的唯一索引是uk_biz_type_biz_id消费前先尝试插入插入成功的进程获得“执行权”其他进程则撞索引失败。这里有个实际经验幂等记录表最好把状态字段加上并且把第一次插入后的更新操作也设计好因为同一笔消息有可能第一次消费后业务执行失败需要把它标记为“可重试”否则失败消息会被永久拦截掉造成漏单。4.4 消费流程的完整串联上面几块拼起来就是一个完整的消费流程从MQ获取消息解析出业务类型和业务ID。生成幂等键biz_type biz_id。先查幂等记录表看状态是否为“处理完成”如果是直接ack并返回。尝试在Redis里抢占幂等锁。抢锁失败说明消息正被处理或已处理过直接ack返回。Redis锁抢到后尝试往幂等记录表插入一条状态为“处理中”的记录。如果插入冲突说明有并发执行者解锁并返回。执行真正的业务逻辑。业务执行成功后更新幂等记录表状态为“处理完成”释放Redis锁ack消息。业务执行异常回滚记录表或改为失败状态释放锁不ack交给MQ重试。这个流程写出来简单但每一步都有值得警惕的细节。我这里给个参考实现注意看注释public function consume($msg) { $body json_decode($msg-getBody(), true); $bizType $body[biz_type]; $bizId $body[biz_id]; $bizKey $bizType . : . $bizId; // 1. 先查状态快速过滤已完成的消息 $record $this-idemRepository-findByBizTypeAndBizId($bizType, $bizId); if ($record $record[status] 2) { $msg-ack(); return; } // 2. Redis抢锁 if (!$this-idemConsumer-tryLock($bizKey)) { $msg-ack(); return; } // 3. 数据库去重表再插一次防止锁过期导致的双重处理 try { $recordId $this-idemRepository-insertPending($bizType, $bizId); } catch (DuplicateKeyException $e) { // 另一个进程抢先插入了直接放弃本轮的消费 $this-idemConsumer-unlock($bizKey); $msg-ack(); return; } try { // 4. 业务逻辑 $this-bizService-handle($body); // 5. 标记完成 $this-idemRepository-markCompleted($recordId); $this-idemConsumer-unlock($bizKey); $msg-ack(); } catch (\Throwable $t) { // 6. 异常处理删除记录或标记为失败让MQ重试 $this-idemRepository-markFailed($recordId); $this-idemConsumer-unlock($bizKey); // 注意这里不ack让MQ重新投递 throw $t; } }第三点是整套流程里最容易被忽略的为什么先查状态之后还要Redis抢锁抢了锁之后还要插数据库因为这三个步骤拦截的场景不同。状态查询拦截的是“已完成”消息是第一次快速通道Redis锁拦截的是并发窗口期——如果不存在数据库兜底一旦锁过期另一个进程就会冲进来数据库唯一索引拦截的是“锁过期后并发进程同时执行”的最终场景。三层拦完才敢说幂等性稳妥了。5. 实战避坑这些细节决定幂等方案能不能活上面那套框架看起来没什么问题但真正上线之后踩坑踩到怀疑人生的地方往往不在主流程而在那些边界细节。5.1 锁过期时间设得太短导致重复消费不久之前我负责的一个积分服务消费一条“订单完成送积分”的消息正常处理耗时也就一两百毫秒当时我图省事把Redis锁过期时间设成了3秒。结果某天数据库出现慢查询一条消息处理耗时超过5秒锁提前过期另一个消费进程拿到了锁两个进程同时处理了同一笔订单。后果就是用户积分加了两次。这个事情的教训有两个第一锁过期时间必须按“最坏情况下的处理耗时”来设置不是平均耗时我会在压测时专门造慢查询场景测出最大耗时再乘以安全系数第二就算锁设得再长也无法百分之百避免过期所以数据库唯一索引兜底不是可选项而是必选项。5.2 锁释放用了del导致误删在没有用Lua脚本之前我见过不少同事在finally里直接写$redis-del($key)释放锁。这会导致一个经典问题进程A处理消息耗时较长锁的key已经自动过期了进程B加锁成功并开始处理同一条消息。此时进程A处理完毕执行del直接把进程B持有的锁删掉了。随后进程B拿到的锁也形同虚设如果又来一个进程C也能顺利加锁。解决办法就是前面提到的Lua脚本释放前先比较value只有value匹配才删除。虽然现在Redis客户端扩展很多都封装了compareAndDelete之类的方法但我还是推荐你随手写上Lua脚本不依赖扩展特性到哪儿都不会错。5.3 消息失败后重试导致“假重复”这里要区分两个概念重试消息和重复消息。重试消息是因为消费失败被MQ重新投递本质上它需要被再次处理重复消息是被消费成功后MQ又重复投递它不该被再次处理。很多方案容易在这两种消息上栽跟头如果去重表一旦插入成功就永久标记为“已完成”那么业务执行失败后想重试时去重表会拦截掉本次重试导致这条消息永远处理不了。所以设计去重表时一定要保留状态字段并且把“处理中”和“处理完成”区分开。只有“处理完成”的消息才直接跳过“处理中”的消息要结合锁和超时机制判断是否可以继续处理。我上面的实现里失败时会markFailed删除记录就是给MQ重试留出通道。这种“处理中可重试、完成态不可再试”的设计是幂等方案能长期稳定运行的灵魂。5.4 数据库事务与去重插入不在同一连接这条坑非常隐蔽。因为幂等记录表和业务表可能分属不同数据库或者用了不同的数据库连接有些团队会写成“先插入幂等记录提交再开事务更新业务表”。如果第二步失败幂等记录已经存在了消息重试时就被拦截形成漏单。推荐做法是能放在一个事务里就放在一个事务里。如果确实跨库就必须把“插入幂等记录”和“业务表更新”设计成显式的补偿流程——要么在业务失败时回滚幂等记录状态即前面的markFailed要么引入两阶段逻辑让幂等记录表允许“处理中”状态保留一段时间由后台任务定时清理超时的“处理中”记录。我个人的做法是优先单库事务内完成其次才是跨库补偿。跨库会引入分布式事务问题复杂度至少翻一倍非必要不要走这条线。5.5 Redis不可用时怎么办强依赖Redis做幂等拦截的系统一旦Redis宕机最直接的影响是tryLock会抛异常。这个异常会让消费者逻辑中断所有消息都无法消费。这在业务上往往比重复消费更可怕。缓解方案有三种一是给Redis操作加降级开关Redis不可用时直接跳过Redis锁只依赖数据库唯一索引兜底。这样一来并发窗口期变长但数据库唯一索引能保证最终不会重复处理最多是并发瞬间有些请求在数据库层撞索引。二是Redis本身做高可用避免单点。三是所有幂等拦截以数据库为准Redis只是加速层这其实是前面一直强调的“数据库兜底”思想。5.6 压测时如何模拟重复消息很多团队上线前不做幂等压测其实做起来并不复杂。关键在于构造“并发重复消息”从同一个消息ID复制出几十条消息在同一时间投递到MQ然后观察消费端处理的最终结果。如果订单只被处理一次去重表里只有一条记录说明拦截有效如果出现多条处理记录或者业务数据被改多次那就是幂等方案有漏洞。我在真实压测中还会加一个场景消费一半时手动kill掉进程模拟处理中断后MQ重新投递看幂等表里“处理中”状态能否被正确清理或重试。这个场景比单纯并发重复消息更接近线上真实故障。这里有一个很实用的排查手段在框架里给每条消息加上一条日志链路包含biz_id、biz_type、当前进程ID、是否拿了锁、是否插入去重记录。压测时盯着日志看一旦发现重复处理马上能定位到是哪个环节放过了它。6. 压测、监控与运营维护方案落地后衡量它好不好用不能只看“代码能不能跑”要看它上线后面对真实流量扛不扛得住、出问题时能不能快速定位。6.1 幂等压测指标与脚本思路幂等压测的核心指标有三个重复消息拦截率、处理完成率、平均响应耗时。重复消息拦截率要达到100%跑完压测后去重表里每条biz_id只能有一条有效记录。处理完成率是指压测结束后所有消息要么成功、要么处于可重试状态不能有被“吞掉”的消息。构造压测脚本时不建议只测“发送相同的一批消息”更好的做法是把正常消息和重复消息混在一起重复比例从10%逐步提到100%观察消费端的处理时间会不会因大量去重判断而飙升。曾经有个项目因为每次消费都要先查MySQL幂等记录压测时重复消息比例到60%后数据库压力直接翻倍。后来我优化为“Redis快速拦截 数据库最终兜底”这个性能瓶颈才算解决。6.2 监控指标与告警给幂等消费加入监控体系我关注的核心指标如下指标说明上升/下降说明什么锁冲突次数尝试抢锁但失败的次数明显上升可能意味着存在大量重复投递或锁过期时间过短去重拦截数被数据库唯一索引拦下的消息数正常情况下和锁冲突数应基本对应消费失败重试数业务失败进入重试的消息数上升说明业务逻辑有bug需查异常日志处理时长P99最慢的1%消息处理耗时锁超时时间必须大于该值否则就要调参未完成任务数幂等记录表中“处理中”状态的老化数量持续增长说明有消息卡死需要人工介入这些指标用现有的日志监控平台就能搭。我的习惯是给每个指标配一个简单告警比如“锁冲突次数5分钟内持续超过X”或“消费失败重试率超过阈值”一旦触发告警先看日志定位是重复消息还是业务代码问题不要盲目重启消费者。6.3 日常清理与数据归档幂等记录表会随业务量增长持续膨胀。如果只增不清理最终查询会越来越慢。我建议按天或按周对已完成的消息做归档超过7天的记录迁移到归档表或者直接物理删除。但注意归档时不要影响“处理中”状态的判断一般来说超过7天还在“处理中”的消息大概率是卡死了需要人工核对不能直接删。清理计划建议放到业务低峰期执行并且用分批删除一次几千条避免长时间持有行锁拖垮主库。这里我踩过坑最初用一条DELETE FROM idempotent_record WHERE update_time ...直接跑单表几千万条数据把这一个SQL跑了近十分钟期间线上业务全部变慢。后来改成按主键ID分批循环删问题消失了。6.4 多人协作时的开发规范幂等方案能在团队里稳定运行靠的还有开发规范。我在项目里会强制三条要求第一凡是消费MQ消息或处理外部回调的代码必须明确写出biz_type和biz_id的生成规则并且在代码评审时单独过一遍第二业务逻辑里新增“跳过去重表直接处理”的后门是不允许的这种后门会破坏整体防线第三凡是改动幂等框架代码必须配套压测回归不能只改主流程不动压测脚本。这些规范听起来简单真正推下去最大的阻力是“省事”心态。但幂等这件事靠的就是“程序员的自觉 框架的强制约束 数据库的最终兜底”三层防线哪一层松了线上迟早付出代价。7. 方案选型总结与我的个人体会写了这么多最后把我个人的权衡思路整理一下方便你在自己的项目里选择。什么时候可以只用数据库唯一索引团队规模小、消息量不大、Redis运维不成熟、业务数据能天然抽出唯一的业务键。这类项目最重要的是简单可靠数据库唯一索引完全够用别为了引入Redis而引入Redis。什么时候必须上Redis 数据库双层拦截消息QPS高、重复率高、业务敏感涉及余额、积分、库存、允许偶发超时重试但不能接受并发重复执行。这类项目必须双层防护Redis负责速度和大部分拦截数据库负责最终正确性。什么时候需要引入状态机业务本身有明确状态流转比如订单、工单、审批流。状态机是业务天然的表达方式优先级要放在其他幂等方案之上。即便你做了Redis锁和数据库去重表状态判断这一步依然不要省略它能在恢复执行、人工补偿时提供额外的安全性。我在实际项目中逐渐形成的一个经验是先想办法让业务表达本身具备幂等性再谈技术拦截。很多时候我们绞尽脑汁搭各种幂等框架但业务模型本身设计成不可重复执行的样子才是最省心的。比如订单状态流转设计成“状态只能从A到B、不能乱序跳变”这本身就是最扎实的幂等。技术层的Redis锁和唯一索引反而只是给这个业务模型加上了一道保险栓。另外还有一点很重要幂等不是只做一锤子买卖它要伴随着业务演进持续维护。今天消息里的字段是三个明天加了两个今天只用订单号做幂等键明天同一笔订单可能要分多次操作……这些变化都会冲击现有幂等方案。所以我把幂等键生成规则、锁超时时间、兜底表结构都写进了项目的架构文档每次有人改动相关代码都要重新过一遍这些参数。最后分享一个小技巧。排查线上疑似重复消费问题时别急着看代码先看幂等记录表。把消息的biz_id拿出来在几个节点上分别查询MQ管理控制台里的投递次数、Redis里锁的剩余TTL、数据库幂等表的状态与更新时间。这三个数据一核对基本五分钟内就能判断出是哪一层放过了重复消息。练熟了这套排查手法处理类似问题时你会稳很多不会被表象带着跑。
返回列表