|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
Sybase ASE 阻塞与锁等待排查实战
一、具体的问题
做 ASE 运维的,大概都遇到过这个场景:业务突然报"某个功能点一下就转圈,转十几秒然后超时",应用服务器的连接池很快被占满。登录数据库执行 sp_who,看到几十个 spid 的 status 清一色是 lock sleep,blk 那一列都指向同一个数字。把源头那个 spid kill 掉,系统瞬间恢复,但是半小时后同样的现象又来了。
这种"kill 一下就好"的处理方式,短期能救火,长期是灾难。我接手的一个账务库,最严重的时候一天要人工 kill 七八次,每次都是同一个单据审核功能。查到最后发现源头是一条 UPDATE,它更新了 20 万行,触发表级锁升级,把整张表锁住;跟它冲突的三十多个 spid 全在等这张表。真正的问题不在那 20 万行,而在于这个事务里还夹着一次远程接口调用,接口慢的时候事务就一直不提交。
排查难在哪?有三个坑:
- sp_who 只告诉你"谁在等我",不告诉你"我等的人又被谁等"。五层以上的阻塞链,靠肉眼一层层追基本追不出来;
- sp_lock 只给你锁,不给 SQL 文本。看到 exclusive table 锁在 acc_bill 上,还得再查一次才能知道是哪个 spid、哪条语句加的;
- 最阴的是"幽灵锁"——客户端用了隐式事务或者 autocommit 关了,一条 SELECT 执行完不提交,锁就一直挂着。应用开发那边以为语句早就结束了,数据库这边事务还开着。
本文按"第一现场 → 追阻塞链 → 拿到源头 SQL → 根治"这条线走,给出可以直接照抄执行的命令和前后对比。
二、核心原理
1. ASE 的锁粒度和锁方案
ASE 的锁分三层粒度:表锁(table)、页锁(page)、行锁(row)。具体用哪一层,由表的锁方案决定:
- allpages:默认的老方案,最小粒度是页锁。一个 2K 页上可能放着几十行,改其中一行,整页被锁,邻居跟着遭殃;
- datapages:索引和数据分开,最小粒度还是页锁,但页上没有索引行,冲突少一些;
- datarows:最小粒度是行锁,并发最好,代价是锁表内存开销大、需要调 number of locks。
这是 ASE 和 Oracle / MySQL InnoDB 最大的认知差:很多人默认以为"我改一行就锁一行",在 allpages 表上完全不是这么回事。
2. 锁类型与兼容性
常用三种:共享锁(shared,读加)、排他锁(exclusive,写加)、更新锁(update,更新前先加这个,避免死锁升级)。共享锁之间兼容,排他锁跟谁都不兼容。lock sleep 状态就是某个 spid 申请的锁跟别人已持有的锁不兼容,被挂起等待。
3. 阻塞链是怎么形成的
A 持锁未提交 → B 等 A → C 等 B → D 等 C。业务看到的是 D 卡住,但根因在 A。而且 ASE 里 B、C、D 可能各自持有别的锁,于是形成一张网。这时候"看到谁杀谁"就是瞎打,必须找到链头——那个 blocked = 0 且没有别人等它、但一堆人等它下游的 spid。
4. 锁升级
ASE 在单个事务对同一对象持有的锁数量超过阈值时,会自动把大量页锁/行锁升级成表锁,目的是省锁表内存。这个行为对并发是致命的:一条批量 UPDATE 本来只是锁一部分页,升到表锁之后整张表谁也进不来。12.5 之后可以用 sp_setpglockpromote / sp_setrowlockpromote / sp_settablelockpromote 按表调阈值,甚至可以关掉某一级的升级。
三、实例参考(动手步骤)
1. 先复现一次阻塞
不要在生产上等故障,搭一个能随时复现的环境。开两个 isql 会话。
会话 1(制造阻塞):
- use accdb
- go
- begin tran
- update acc_bill set status = '9' where bill_date = '2026-09-14'
- -- 注意:故意不 commit
- go
复制代码
会话 2(被阻塞):
- use accdb
- go
- select count(*) from acc_bill where bill_date = '2026-09-14'
- go
复制代码
会话 2 会一直挂着不返回。这就是线上"点一下转圈"的等价物。
2. 第一现场三板斧
换一个会话,依次执行:
看 status 为 lock sleep 的行,记下它的 spid 和 blk(阻塞它的 spid)。
看 locktype 列,如果看到 exclusive table,说明已经发生锁升级;exclusive page / exclusive row 则还在细粒度。
- select spid, blocked, status, cmd, dbid
- from master..sysprocesses
- where blocked != 0
- go
复制代码
blocked 列是 ASE 直接给出的"被谁堵",比 sp_who 的 blk 更适合写进脚本。
3. 一条 SQL 拉出完整阻塞链
用 sysprocesses 自连接,把"我堵了谁"和"谁堵了我"一次查出来:
- select p.spid as 被堵spid,
- p.blocked as 堵它的人,
- p.status as 状态,
- p.program_name as 程序,
- p.cmd as 命令
- from master..sysprocesses p
- where p.blocked != 0
- order by p.blocked, p.spid
- go
复制代码
如果要判断谁是链头(没被任何人堵、但堵着别人):
- select distinct p.blocked as 链头spid,
- h.status, h.program_name, h.loggedindatetime
- from master..sysprocesses p
- join master..sysprocesses h on p.blocked = h.spid
- where p.blocked != 0
- and h.blocked = 0
- go
复制代码
4. 拿到源头在执行的 SQL
锁找到了,还要知道它在跑什么。dbcc sqltext 是这一步的关键:
输出里的 SQL Text 就是该 spid 最近一次执行的语句。如果拿到的是一条很普通的 UPDATE,别急着下结论——看一下事务开了多久:
它会给出最老的活动事务的开始时间、对应的 spid 和日志位置。如果显示"事务已开启 42 分钟",那问题基本就是事务边界而不是 SQL 本身。ASE 15 之后还可以查 master..syslogshold,看是哪个事务在拖着日志不让截断:
- select dbid, spid, starttime, name
- from master..syslogshold
- go
复制代码
5. 前后对比
同一张 acc_bill 表,治理前后实测:
| 对比项 | 治理前 | 治理后 | | 锁方案 | allpages(页锁) | datarows(行锁) | | 批量 UPDATE 20 万行的锁粒度 | exclusive table(整表) | exclusive row | | 并发阻塞 spid 峰值 | 37 | 3 | | 业务平均响应时间 | 12.4 秒 | 0.6 秒 | | 日均人工 kill 次数 | 7~8 次 | 0 | | 事务平均持有时长 | 95 秒 | 1.8 秒 |
改锁方案这一步:
- alter table acc_bill lock datarows
- go
复制代码
注意它会重建表,需要停机窗口和足够的空间,别在线上高峰直接跑。
6. 四类根治动作
- 缩短事务边界:把远程调用、文件读写、人工确认这些慢动作全部挪出事务,BEGIN TRAN 和 COMMIT 之间只留数据库操作。这一条的收益通常最大;
- 让语句走索引:UPDATE ... WHERE bill_date = ? 如果 bill_date 上没索引,就是全表扫描 + 全表加锁。建索引后再看 sp_lock,锁的范围会小很多;
- 按表调锁升级阈值:对热点表抬高层级门槛,例如 sp_setrowlockpromote('server', 'accdb', 'acc_bill', 0, 5000),把行锁升级阈值从默认抬到 5000,避免批量操作一上来就升表锁;
- 给等待设上限:sp_configure 'lock wait period', 10,让被堵的语句 10 秒后返回超时错误(1205),而不是无限期挂着把连接池耗干。应用侧捕获 1205 做重试比让用户干等体验好得多。
读多写少的报表场景,可以考虑在会话级降隔离级别,避免读被写堵住:
- set transaction isolation level 0
- go
- -- 或者只对单条语句生效
- select count(*) from acc_bill at isolation read uncommitted
- go
复制代码
ASE 15.7 起对 datarows 表还支持 readpast 提示,跳过被锁的行,适合队列类表取任务。
四、实操检查清单
- [ ] 装上 sp_who / sp_lock 的定时采样(每 10~30 秒一次写入历史表),故障时才有"当时的现场",不用靠运气撞上。
- [ ] 每次阻塞告警,先定位链头 spid(blocked = 0 但堵着别人),不要对着被堵的 spid 乱 kill。
- [ ] 拿到链头后必做 dbcc sqltext(spid),确认它跑的是什么语句。
- [ ] 用 dbcc opentran(dbname) 或 master..syslogshold 确认事务开启时长,超过 30 秒的优先查事务边界。
- [ ] 检查热点表的锁方案,仍是 allpages 的评估改造为 datarows,改造前评估空间与停机窗口。
- [ ] 检查 sp_lock 输出里是否频繁出现 exclusive table,出现即说明发生锁升级,需要用 sp_set*lockpromote 调阈值或拆分批量。
- [ ] 确认批量 DML 的 WHERE 条件有可用索引,避免全表扫描导致全表加锁。
- [ ] 应用侧排查:是否有 autocommit 关闭、是否有隐式事务未提交、连接归还池之前是否有 rollback。
- [ ] 配置 lock wait period(建议 10~30 秒),并让应用捕获错误 1205 做有限次重试。
- [ ] 大批量删除/更新改成分批循环(每批 5000 行 + 显式 commit),把长事务拆成短事务。
- [ ] 建立基线:记录日常 lock sleep 数量、最长事务时长,作为告警阈值依据,别拍脑袋定阈值。
|
|