☰
两条相同UPDATE互相死锁:执行计划与LOB索引根因排查
2026/10/27 19:45:26 网站建设 项目流程

简介:面向SQL Server数据库开发与运维人员的Deadlock案例分析文档,聚焦SQL Server 2008上可稳定重现的疑难死锁:同一张表上的两条相同UPDATE语句为何互相阻塞,以及索引INCLUDE列或varchar(max)字段类型变化后现象为何消失。文档围绕表tt的聚集索引、两个非聚集索引和rowlock更新循环展开重现,结合SQL Trace、DBCC TRACEON(1222,-1)、Profiler事件过滤、sp_readerrorlog及deadlock-list输出,梳理锁等待、索引包含列与字段类型之间的关联和排查路径。资源包共1个docx文件,约694KB,以案例记录和SQL语句为主,便于按步骤复现与对照分析。已有326人学习下载,适合数据库开发与运维读者参考。

1. 两条一模一样的 UPDATE 互相死锁:不是玄学,是执行计划在作祟

两条文本完全一致的UPDATE,同一个 WHERE 条件、同样带着rowlock提示,从两个连接同时跑,几秒钟内就互相顶死——这是我处理过最反直觉的一类 SQL Server Deadlock(死锁)。它不像典型的交叉更新那样「各持一把锁互不相让」,而是在三个索引之间错位:一方在非聚集索引上攥着 U 锁等聚集索引,另一方在聚集索引上攥着 X 锁等那个非聚集索引。更让人抓狂的是,把索引上的include(d)去掉,或者把d从varchar(max)改成varchar(200),死锁立刻消失,语句一个字都没改。这类「改个类型就好了」的故障最容易把人带进玄学,也最容易在生产上悄悄复发。下面把稳定复现、1222 开关、SQL Trace 取证、执行计划对比这条完整链路走一遍,手上正卡着类似死锁的,可以照着顺序抄。

2. 稳定复现:三个对照实验把死锁固定在测试环境

排死锁最怕的就是「生产上偶发、测试环境不出现」。这个案例最大的价值,是它能在测试库上用十几行脚本稳定重现,而且三个对照实验把变量卡得死死的:只动 INCLUDE,只动数据类型,其他一律不变。这意味着后面抓到的一切差异,都能直接归因到这两个变量上,不用再猜是不是并发量、是不是数据分布、是不是某个定时任务撞上了。我做死锁排查有个固定习惯:先想办法把它变成「按一下按钮就出现」的东西,做不到就先别急着开 Profiler,因为你没法判断抓到的那次是不是同一次。

2.1 建表、灌数、定位那一条记录

先把结构搭出来。一张表,一个聚集主键,两个非聚集索引,索引上都把d字段 INCLUDE 进去。这个设计本身在生产里极其常见——业务方希望where a = ?和where b = ?都能走覆盖索引,于是把经常一起查的宽字段塞进 INCLUDE。

create table tt( id int identity primary key, -- 聚集索引,后面死锁的另一方 a char(36), b char(36), d varchar(max) -- 注意:LOB 类型,且被两个索引 INCLUDE ); go create index ix_a_bc on tt(a) include(d); create index ix_b_cd on tt(b) include(d); go

接着灌数据。这里用go 10000让同一批语句重复执行一万次,每次插一行,最终一万行,a字段用NEWID()造随机值保证选择度:

insert into tt select NEWID(),'bbb','ddd'; go 10000

灌完之后随便挑一条记录,把它的a值取出来备用。取id = 10这条:

select * from tt where id = 10;

假设拿到的a值是EF211985-EA72-4A40-81DA-0AAB076E7AA3。这个值是随机生成的,你每次跑都会不一样,看到不同的值不用怀疑环境有问题。记住它,后面所有的更新语句和筛选条件都围绕它展开。

提示:复现脚本只在自己的测试库或隔离环境里跑。while 1=1加上无限制的 UPDATE 循环,在共享环境里会直接把别人正在用的表锁住,别在自己还没搞清楚状况之前就去生产上「试一下」。

2.2 三个对照测试:加不加 INCLUDE、改不改类型

测试 1 是基线,也是最容易让人怀疑人生的那一步。开两个查询窗口,各自跑下面这段循环:

-- 测试 1:d 为 varchar(max),且被两个非聚集索引 INCLUDE while 1 = 1 update tt with(rowlock) set d = 'cd' where a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3';

两个连接跑到几秒内,必定有一个被选为死锁牺牲品,报 1205。注意这里两个连接的执行计划、参数、锁提示完全一样,没有任何「一个先一个后」的时序差异可赖,这就把「并发时序问题」这个甩锅理由堵死了。

测试 2 只改一件事:把两个非聚集索引重建,去掉 INCLUDE。

-- 测试 2:去掉 INCLUDE(d),其余完全不变 drop index ix_a_bc on tt; drop index ix_b_cd on tt; create index ix_a_bc on tt(a); create index ix_b_cd on tt(b);

同样的循环跑十分钟,死锁不出现。这一步说明d是不是被索引覆盖,直接决定了 SQL Server 要不要额外去动那两个非聚集索引。

测试 3 把 INCLUDE 加回来,但把字段类型收窄:

-- 测试 3:INCLUDE(d) 保留,但 d 从 varchar(max) 收窄到 varchar(200) alter table tt alter column d varchar(200);

死锁同样不出现。到这里三个实验形成了一个干净的三元组:类型宽 + 被索引覆盖 = 死锁;类型宽 + 不被覆盖 = 无死锁;类型窄 + 被覆盖 = 无死锁。所有后续分析都只需要解释这三条规律为什么成立。

注意:网上流传的版本里,测试 2 的重建脚本有一处照抄陷阱——它写着「去掉 include」,但create index语句里include(d)还留着。照着原样执行,你得到的是测试 1 的结果,然后就会开始怀疑人生:「明明按文章做了,怎么还是死锁」。重建索引之后务必用sp_helpindex tt或者查sys.indexes确认d真的不在索引列里了。

2.3 为什么必须先能稳定复现

死锁排查里,有 80% 的时间浪费在「抓不到」。生产上偶发的死锁,你打开 Profiler 等了两个小时没出现,关掉之后十分钟又报了一次。这种情况下抓到的 trace 往往信息不全,你只能看着一个 Deadlock Graph 里的两个椭圆发呆,根本不知道谁先谁后。

能在测试环境稳定复现,带来的好处不只是「多抓几次」。它让你可以主动做变量隔离:这次只开 rowlock,下次加上set statistics profile on看计划,再下次只过滤两个 SPID 抓锁事件。每次只动一个变量,结论才站得住。我一般会在这个阶段先写一张小纸条,把「改了什么 / 结果是什么」列出来,后面读 1222 输出的时候对着看,能省掉大量来回验证的时间。

另外,复现脚本本身也是一份证据。等你分析完给出优化方案,回头用同一份脚本跑一遍,死锁不出现,这就是最有说服力的验证。比在评审会上讲一百句「应该没问题了」都管用。

3. 抓现场:1222 开关与 SQL Trace 的取证顺序

死锁一旦发生,SQL Server 自己其实已经把「案发现场」记下来了,问题在于大多数人不知道去哪拿、拿到之后看哪几行。SQL Server 里定位死锁的直接手段有两个:开启 1222 跟踪标志,让死锁信息自动写进错误日志;或者用 SQL Trace / Profiler 抓锁的申请与释放事件。这两个不是二选一,而是互相补位——1222 给你「谁持有什么、谁在等什么」的结果快照,Trace 给你「按什么顺序拿的、按什么顺序放的」时间线。缺了后者,你只能看到死锁的结果,看不到它形成的过程,也就没法解释「为什么改个类型就好了」。

3.1 打开 1222 让死锁自己写进错误日志

先开跟踪标志。-1表示对所有连接生效,不加这个参数只在当前会话有效,很容易出现「我明明开了啊」的尴尬:

dbcc traceon (1222, -1);

然后确认每个连接自己的 SPID,后面做 Trace 过滤要用:

select @@spid;

假设两个跑循环的连接分别拿到 54 和 60。把这两个数字记下来,它们是后面所有过滤条件的锚点。

死锁发生后,从错误日志里把记录捞出来:

exec sp_readerrorlog 0, 1, N'deadlock-list';

参数含义:第一个0表示读当前错误日志(1是上一个归档,2是更早的,排查隔夜问题时要往上翻);第二个1表示读 SQL Server 错误日志而不是 Agent 日志;第三个是内容过滤关键字,加上它能直接跳过几千行无关的备份、启动信息。如果不加过滤,你会在一堆日志里翻很久。

注意:1222 的输出是按「每次死锁一段」追加到错误日志里的,日志轮转之后老记录就没了。如果问题已经发生过一段时间,先确认错误日志还没被回收,必要时把sp_readerrorlog的结果整段导出来留档,别等到分析到一半发现记录被冲掉了。

3.2 Profiler 里只勾这几个事件和列

开 Trace 之前先把事件选对。新建 Trace 时勾上「显示所有事件」和「显示所有列」,然后在 Locks 分类下勾选锁的申请、释放、死锁、死锁链这几类事件;在 TSQL 分类下勾选语句开始、语句完成的事件。列一定要把Mode、ObjectID、ObjectID2、Type拖到前面来——这几个列默认排在很后面,不拖出来你根本没法快速扫读。

接下来在「列筛选器」里对 SPID 做过滤,只保留 54、60 以及需要观察的系统会话。这一步能把你抓到的数据量从几万行压到几百行,是能不能读得下去的关键。跑起两个循环,让死锁发生,然后立刻停掉 Trace,别再往里灌数据。

3.3 读 1222 输出的三个关键段

1222 的输出很长,但真正要看的只有三段。第一段是deadlock victim,告诉你谁被牺牲了,这决定了你要从哪个连接的角度去读时间线。第二段是process-list,每个进程一段,里面有waitresource(它在等什么)、lockMode(用什么模式等)、isolationlevel、trancount,以及executionStack里的语句原文。

第三段是resource-list,这是最关键的一段,因为它把「谁持有、谁在等」摆在一起。把无关字段去掉之后,结构大致是这样:

deadlock-list deadlock victim=process5e27708 process-list process id=process5e27708 spid=60 lockMode=X status=suspended waitresource=KEY: 8:72057594066108416 (ef8a9edf5a1e) executionStack ... UPDATE [tt] WITH(rowlock) set [d]=@1 WHERE [a]=@2 process id=process5e09dc8 spid=54 lockMode=U status=suspended waitresource=KEY: 8:72057594065518592 (d08358b1108f) executionStack ... UPDATE [tt] WITH(rowlock) set [d]=@1 WHERE [a]=@2 resource-list keylock hobtid=72057594066108416 indexname=ix_a_bc mode=U owner id=process5e09dc8 mode=U waiter id=process5e27708 mode=X keylock hobtid=72057594065518592 indexname=PK__tt__3213E83F10E07F16 mode=X owner id=process5e27708 mode=X waiter id=process5e09dc8 mode=U

读法很直接:连接 54 在ix_a_bc上持有一个 U 锁,正在等聚集索引上的 U 锁;连接 60 在聚集索引上持有 X 锁,正在等ix_a_bc上的 X 锁。一个「持 A 等 B」,一个「持 B 等 A」,标准的环形等待。

这里有个容易看漏的点:两边等的是同一个表的两个不同索引,而不是两行数据。这也是为什么「两条完全一样的语句」能死锁——它们各自在不同阶段停下来,卡在了对方的下一步上。

3.4 hobtid 到索引名的映射

1222 输出里hobtid是一串大整数,indexname有时会给,有时只给一个系统生成的聚集索引名,光看名字很难对上号。hobtid实际上就是sys.partitions里的partition_id,用它反查就能把编号和索引名对应起来:

select o.name as table_name, i.name as index_name, i.type_desc from sys.indexes i join sys.objects o on i.object_id = o.object_id join sys.partitions p on p.index_id = i.index_id and p.object_id = i.object_id where p.partition_id = 72057594065518592; -- 换成 1222 里的 hobtid

逐个把 hobtid 代进去,就能得到一张对照表。这个案例里三个值分别对应聚集主键、ix_a_bc和ix_b_cd。把这张对照表写在纸上贴在屏幕边上,后面读 Trace 的时候会反复用到,比每次现查快得多。

Trace 里的ObjectID2字段存的就是同一个 hobtid,所以这张对照表在 Trace 分析里同样管用。很多人觉得 Profiler 的输出难读,其实难读的原因就是没做这一步映射——满屏数字当然读不下去,换成索引名之后,整个加锁顺序一眼就能串起来。

4. 拆根因:一条 UPDATE 为什么变成两次加锁、三把 X 锁

1222 只告诉你死锁的最终状态,回答不了「为什么会走到这个状态」。要解释这个问题,必须看一次「没有死锁时」的完整加锁放锁过程,把它和死锁时的状态对齐。这也是我处理死锁时固定会做的一步:先找一条跑得顺的、单独执行的语句,把它的锁序列抓下来,这就是「正常路径」;再拿死锁现场去比,差异出现的位置,就是根因所在。

4.1 正常路径:一次 UPDATE 的加锁放锁序列

单独跑一次测试 1 环境下的 UPDATE,用 Trace 抓Lock:Acquired和Lock:Released,按时间排序后能得到这样一张表:

阶段动作索引锁模式
第一步按 a 定位记录ix_a_bcU 申请
第一步找到聚集索引记录聚集主键U 申请
第一步更新聚集索引聚集主键U 转 X 申请
第一步定位完成ix_a_bcU 释放
第二步更新被覆盖的非聚集索引ix_a_bcX 申请
第二步更新被覆盖的非聚集索引ix_b_cdX 申请
第二步语句结束三个索引X 依次释放

这张表说明了一件很重要的事:这条 UPDATE 在测试 1 的索引结构下,是分两步走的。第一步通过ix_a_bc找到目标行,更新聚集索引;第二步因为d被两个非聚集索引覆盖,还得回头把那两个索引也更新一遍。

注意第一步结束时的那个动作——ix_a_bc上的 U 锁被释放了。这是 SQL Server 的常规优化:定位阶段用完的 U 锁不保留到语句结束,避免无谓地阻塞别人。单独跑的时候这是好事,两个连接撞上的时候,它就是死锁的开关。

4.2 U 锁与 X 锁的错位是怎么发生的

把两个连接的动作按时间交错排一下,死锁的形成过程就非常清楚了:

连接 2 先跑完第一步,它在聚集索引上拿到了 X 锁,同时把ix_a_bc上的 U 锁释放掉,进入第二步,准备申请ix_a_bc的 X 锁。就在这个空隙里,连接 1 开始执行,它发现ix_a_bc上没有冲突的锁(因为连接 2 刚把 U 锁放了),顺利拿到 U 锁,然后按流程去申请聚集索引上的锁——撞上了连接 2 的 X 锁,被挂起等待。

与此同时,连接 2 申请ix_a_bc的 X 锁,撞上了连接 1 刚拿到的 U 锁,也被挂起。两边互相等,环形成,SQL Server 的死锁检测机制介入,挑一个代价小的回滚,报 1205。

这里最反直觉的地方在于:连接 1 之所以能「插进来」,恰恰是因为连接 2 老老实实把 U 锁释放了。如果 U 锁一直持有,连接 1 根本进不来,最多是等一会儿,不会有死锁。也就是说,锁的及时释放反而扩大了并发窗口,把两个本来串行的操作变成了交错执行。

提示:with(rowlock)在这里起的是「加速器」作用,不是根因。它让锁粒度变细、冲突窗口变多,同样的逻辑在默认粒度下发生概率低一些,但结构性问题还在。所以遇到这类死锁,别急着把 rowlock 去掉当成解决方案,那只是把概率从「必现」压到「偶发」。

4.3 执行计划里的三个 Index Update

锁的行为是结果,执行计划才是原因。在 UPDATE 前面加上set statistics profile on,拿到文字版的计划,能直接看到差异:

set statistics profile on; go update tt with(rowlock) set d = 'cd' where a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3'; go set statistics profile off; go

测试 1 的计划里有三个Index Update算子,分别对应聚集索引、ix_a_bc、ix_b_cd。三个算子意味着三次独立的索引更新动作,也意味着三把独立的 X 锁,而且它们是在计划的执行序列里分先后拿的——这就是 U 锁被提前释放、并发窗口被打开的结构性原因。

测试 2 的计划里只剩一个Index Update。因为d不在任何非聚集索引里,改了它只需要动聚集索引,那两个非聚集索引压根不用碰,自然没有第二阶段的 X 锁申请,也就没有死锁。

测试 3 的计划最有意思:只有一个Index Update算子,但它包含了三个 Object。也就是说,SQL Server 一步就把三个索引全更新掉了,不需要分阶段。计划里算子的数量看着一样是一个,实际做的事完全不同,这也是为什么光看执行计划的图形结构容易误判,一定要看算子下面的对象列表。

4.4 为什么 varchar(max) 会逼出两步执行

varchar(max)在 SQL Server 里是 LOB 类型。超过一定长度的数据不放在行内,而是通过一个 16 字节的指针指向行外的 LOB 页。正因为数据不在行内,SQL Server 没法在一次行更新里同时把三个索引都改掉——每个索引里的那条记录都得单独处理一次,于是计划被拆成多个Index Update算子。

把类型收窄到varchar(200)之后,数据完全在行内,SQL Server 可以在一次更新里把聚集索引和两个非聚集索引一起改掉,计划里就只有一个算子,一次行更新连带的索引维护全部完成。锁的申请也从「分阶段两次」变成「一次拿齐」,中间没有 U 锁释放的空隙,别的连接插不进来。

顺带解释一下为什么varchar(8000)也不死锁——它同样在行内,同理。真正触发问题的是 LOB 类型这个属性,不是max这个字面写法本身。

所以三条结论可以落下来了。第一,SQL Server 的加锁数量和加锁顺序由执行计划决定,不是由语句文本决定,两条一模一样的语句在不同索引结构下可能拿到完全不同的计划。第二,表上的索引越多、字段类型越复杂,计划越复杂,锁的步数越多,遇到阻塞和死锁的概率就越高。第三,消除死锁可以从调整执行计划入手,引导 SQL Server 申请更少的锁,而不是一味加锁提示、加重试。

5. 避坑清单:五类「明明改了却没生效」的死锁排查事故

死锁分析最消耗人的地方,不是看不懂 1222,而是你按着某个套路做完之后发现「没效果」,然后不知道下一步该干嘛。下面这五条都是我在真实排查里栽过的,每一条都写清楚现象、原因和具体怎么处理,遇到相似的场景可以直接对号入座。

5.1 测试环境怎么跑都不复现,生产上一天报三次

现象:拿到生产死锁告警,照着语句在测试库上开两个连接跑,跑了一下午什么事都没有,一回到生产又开始报 1205。

原因:语句一样不代表计划一样。测试库数据量小、统计信息不同,优化器很可能选了另一个索引,甚至走了完全不同的连接顺序。计划一变,锁的数量和顺序全变,原来那条死锁路径根本不存在。

解决:先在测试库把数据量灌到和生产一个量级,再对相关表做一次统计信息更新,然后重新看计划。如果还是对不上,用option (recompile)让语句强制重新编译,排除计划缓存里那份「历史遗留计划」的干扰。实在复现不出来,就老老实实去生产抓 1222 输出,用真实数据反推,别在测试环境里死磕。

-- 让相关表统计信息刷新,避免优化器拿着过期基数估错计划 update statistics tt with fullscan; go -- 单独验证某条语句在这份数据下的真实计划 update tt with(rowlock) set d = 'cd' where a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3' option (recompile);

5.2 加了 rowlock 之后死锁反而变多了

现象:为了减少锁冲突,给热点更新语句统一加上with(rowlock),结果死锁告警不降反升。

原因:rowlock只是把锁的粒度请求到行级,它不会减少锁的「次数」。这个案例里,粒度变细之后并发窗口变大,U 锁提前释放带来的空隙更容易被别的连接钻进来,交错执行的组合数直接上升。粒度小和冲突少不是一回事。

解决:先看清楚死锁是粒度问题还是顺序问题。顺序问题加什么粒度提示都没用,得从索引结构和执行计划下手。如果确实需要让某段逻辑串行化,可以反过来考虑在事务里用with(updlock, holdlock)让 U 锁一直持有到事务结束,主动关掉那个并发窗口——代价是并发度下降,得权衡。

5.3 开了 1222,错误日志里却什么都没有

现象:执行了dbcc traceon (1222, -1),死锁也确实复现了,但sp_readerrorlog里搜不到deadlock-list。

原因:最常见的是-1没生效或者没加,只在当前会话开了,别的连接上的死锁照样不记录。其次是日志轮转,问题发生之后又跑了几个小时,老日志被归档甚至被回收,当前日志里自然找不到。

解决:确认跟踪标志状态,先把-1加上,然后新开连接复现。查日志时按序号往上翻,sp_readerrorlog 0,1、sp_readerrorlog 1,1、sp_readerrorlog 2,1依次往前找,同时把命中的整段导出来留档,别指望它一直在那儿。

-- 确认 1222 当前是否处于开启状态 dbcc tracestatus (1222); go -- 依次向前翻归档日志,关键字过滤 exec sp_readerrorlog 1, 1, N'deadlock-list'; exec sp_readerrorlog 2, 1, N'deadlock-list';

5.4 只看了 Deadlock Graph 就下结论

现象:从监控平台导出一张死锁关系图,看到两个椭圆互相指向,判断「是交叉更新导致的」,改了业务代码的执行顺序,问题没解决。

原因:死锁关系图只呈现最终状态——谁持有、谁等待,它不告诉你锁是按什么顺序拿的、中间释放过什么。这个案例里,如果只看图,你会以为两边是同时各拿一把锁,实际是连接 2 先释放了 U 锁才让连接 1 挤进来。少了这一步,任何修复方案都只能靠猜。

解决:死锁图当索引,不当证据。拿到图之后立刻去翻 1222 的resource-list确认持锁模式,再去 Trace 里看Lock:Acquired/Lock:Released的时间序列,把「谁在什么时候放了什么锁」还原出来。这三份材料对上了,结论才可信。

5.5 把 INCLUDE 去掉,死锁没了但查询慢了三倍

现象:按分析结论把include(d)去掉,死锁确实不再出现,但原来走覆盖索引的查询开始出现大量键查找,页面生命周期和 CPU 一起飙升。

原因:INCLUDE 存在的意义就是让查询变成覆盖索引,避免回表。去掉它等于用读性能换写并发,是一笔没算清楚的账。

解决:优先改类型而不是删 INCLUDE。把varchar(max)收窄到业务真实需要的长度(比如 200、500),既保住了覆盖索引,又让更新回到单步执行。如果字段确实需要存长文本,那就把它从索引里移出去,同时确认相关查询能不能接受回表,或者换个更窄的字段做覆盖。这个决策要和业务查询一起看,不能只从死锁这一个角度拍板。

6. 快速定位:从死锁报错到索引落地的排查路径

再遇到「两条一样的语句互相死锁」这种场景,我现在的固定动作是四步,顺序不乱。第一步,确认能不能稳定复现,能复现就说明变量可控,不能复现就先别开 Profiler,先去把数据量和统计信息对齐。第二步,开 1222 抓resource-list,先搞清楚「谁持有什么锁、在等什么锁」,这一步只需要两分钟,但能直接排除掉一大半「猜错了方向」的可能。第三步,拿一条单独执行的语句抓Lock:Acquired/Lock:Released,还原正常路径的加锁序列,和死锁现场对比,差异出现的位置就是根因。第四步,set statistics profile on看计划,数一数Index Update算子的个数,算子数量和锁的步数基本是一一对应的。

判断方向上,有个粗略的对照表可以直接用:

现象大概率原因下一步动作
两个连接持锁模式一个 U 一个 X,等的对象是不同索引分阶段更新,中间释放了 U 锁看计划里有几个 Index Update
计划里有多个 Index Update,字段是 LOB 类型LOB 无法行内更新,被拆成多次索引维护收窄字段类型,或从索引里移除
只有一条死锁记录,波及范围很小单条语句的加锁顺序问题别动隔离级别,先动索引结构
加了 rowlock 之后频率上升粒度变细,并发窗口变大去掉提示,回到结构层分析

验证环节同样重要,别改完就完事。我会用同一份复现脚本再跑一遍,同时把改后的执行计划抓出来,确认Index Update从多个变成单个,再确认相关查询的执行计划里没有冒出键查找。这两条都过了才算收工。至于holdlock、计划指南这些手段,属于「业务代码暂时改不了」时的过渡方案,能用,但别当成长期解法——它们把问题藏起来了,而不是解决了。

从那以后我每次碰到处死锁告警,都会强制自己先跑一遍复现脚本,再去看那张 hobtid 对照表,哪怕心里已经有个八成把握的猜测。因为死锁这个东西最擅长的就是让「看起来显然」的结论翻车,血泪经验告诉我,省下的那十分钟,后面往往要用两个通宵补回来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询