dbaai 发表于 昨天 05:49

Sybase ASE 锁升级与隔离级别治理实战:从 1204 锁耗尽到分批提交与升级阈值双线收敛

Sybase ASE 锁升级与隔离级别治理实战:从 1204 锁耗尽到分批提交与升级阈值双线收敛

一、具体的问题

某套生产库,ASE 15.7(16.0 上逻辑完全一样),页大小 4K,明细表 item_detail 一千二百万行,建表时用的是 datarows 锁方案。夜间对账批处理跑完之后,第二天上午十点往后,业务端开始零星报错:


Msg 1204, Level 20, State 1:
ASE has run out of LOCKS. Re-run your command when there are fewer
active users, or contact a user with System Administrator (SA) role
to reconfigure ASE with more LOCKS.


比这个更难受的是新连接根本连不上,客户端直接抛:


Process with spid 214 could not connect to the ASE which has
temporarily run out of locks.


也就是说,锁一耗尽,不是某个慢语句失败,而是整个实例暂时拒绝提供服务。

运维的处理路径非常典型,三步走:先把 number of locks 从 10000 加到 200000,安静了一周;再提到 1000000,安静了两周;最后加到 2000000,锁内存吃到三百多兆,第二天照旧报 1204。而且这一次连 dbcc 都卡住不返回,只能找窗口重启。

真正的问题从来不在锁的数量。它藏在两个地方:批处理的事务边界,以及这张表的行锁升级阈值。

二、核心原理

1. 行锁表上的锁是怎么长起来的

datarows 表的每一行被读写都是独立的锁结构,ASE 内核里每个锁占 184 字节。这不是一个可以忽略的数字:配到 200 万锁,光锁结构就是 200万 × 184 字节 ≈ 351MB 内存。这也是为什么加锁数必须同步看 max memory,否则直接报 5861:


The current 'max memory' value '1792000', is not sufficient to change
the parameter 'number of locks' to '3500000'.


把锁数当成一个可以随便拧大的旋钮,是这个故障反复复发的起点。

2. 锁升级只有两级,判定有三个阈值

ASE 的锁升级(lock promotion)规则很干脆:行锁升级成表锁,没有行锁升页锁这一步。判定用三个参数,LWM(低水位)、HWM(高水位)、PCT(百分比):


[*]当前扫描会话在表上的锁数 小于 LWM:不尝试升级;
[*]大于 HWM:立刻尝试升级为表锁;
[*]处于两者之间:锁数超过「表行数 × PCT%」时尝试升级。


出厂默认:row lock promotion LWM = 200、HWM = 200、PCT = 100。注意 LWM 最小值为 2,PCT 取值范围 1 到 100。

3. 三个致命的细节

第一,计数是「每次扫描会话」重新开始的。一个事务里写六十条 DELETE,每条语句各自计数;单条最多锁住一百九十行,低于 HWM 200,永远达不到升级条件,而整个事务维度的锁数已经累到一万一千多个。锁就这么一条一条堆上去的,跟表的行数没关系,跟语句条数有关系。

第二,升级尝试失败不会重试。白天有其他会话持着冲突锁,升级必然失败,ASE 不排队不等待,继续老老实实拿行锁。这解释了为什么「夜里批处理没事、白天报错」——夜里升级能成功,白天升级失败后行锁继续累积。

第三,调大锁数是纯缓解:任务形态没变,锁峰值没变,只是把水位抬高,顺带把内存顶到墙根。

4. 隔离级别这笔账

ASE 会话默认隔离级别 1(read committed),读的时候加短时共享锁,语句结束就放。级别 0 是脏读,读不加锁;级别 2(repeatable read)把共享锁持到事务结束;级别 3(serializable)还要封住扫描位置防幻影插入,锁量基本翻倍。

只想让报表查询别挡住批处理,at isolation 0 或 readpast 就够了。全局把隔离级别开到 2 或 3,等于用并发换心理安慰。

三、实例参考(动手步骤)

步骤 0:确认现状基线


-- 锁资源水位:Pct_act 顶到 100、Num_free 为 0 就是耗尽
sp_monitorconfig "number of locks"
go

-- 谁在吃锁:找 LocksHeld 的个位数大户
select SPID, LocksHeld, BatchCount, CPUTime, LineNumber
from   monProcessActivity
whereLocksHeld > 1000
orderby LocksHeld desc
go

-- 这张表到底是什么锁方案
select lockscheme("item_detail")
go

-- 服务器级升级阈值
sp_configure "row lock"
go


判据:LocksHeld 集中在个别 SPID 上是单会话问题,不是全局并发问题;lockscheme 返回 datarows 且是批处理主力表,就重点查升级阈值。

步骤 1:抓现行,证明是「事务内累积」而不是「单语句过多」


-- 在监控会话里跑,每 10 秒采一次批处理会话的持锁数
create table lockwatch(spid int, locks_held int, sampled_at datetime)
go
while 1 = 1
begin
    insert lockwatch
    select SPID, LocksHeld, getdate()
    from   monProcessActivity
    whereSPID = 178
    waitfor delay "00:00:10"
end
go


采五分钟就能看出形状:如果 locks_held 是一条一条的阶梯往上爬,每爬一截正好对应一条 DELETE 执行完,说明锁是事务内累积的;如果单条语句就把锁数顶到几万,才需要去看执行计划。

步骤 2:改事务边界,这是治本的第一条

把「一个事务里六十条 DELETE」改成「每批八百行提交一次」:


set rowcount 800
go
while 1 = 1
begin
    begin tran
    delete item_detail
    wherebatch_no = @batch
    and    flag = 0
    if @@rowcount = 0
    begin
      commit tran
      break
    end
    commit tran
end
go


提交一次,事务级行锁全部释放,锁峰值由「几十条语句累积」变成「单批八百行」。这一步通常能砍掉九成的锁占用,而且不需要重启、不需要停机,是最该先做的一步。

步骤 3:下调这张表的升级阈值


-- 在目标库内执行;阈值按 表 > 数据库 > 服务器 的优先级覆盖
sp_setrowlockpromote "table", item_detail, 50, 100, 30
go


含义是:锁数到 50 就开始判,超过表行数的 30% 或者超过 100 个就直接尝试升级为表锁。对批处理来说,拿到一次表锁比拿一万个行锁便宜得多——锁结构省了,升级判断也不再被「单语句锁数上不去」卡住。

反例是把它设成 0、0、0 想「永不升级」,那是把偶发的 1204 变成常态。

步骤 4:读侧降到隔离级别 0,或者用 readpast


-- 报表、巡检这类脏读可接受的查询
select count(*) from item_detail at isolation 0
go

-- 队列式取数:跳过被锁住的行,不排队
select top 50 * from task_queue readpast
go


注意 at isolation 只能加在单条查询或 declare cursor 上,不能用在子查询、insert ... select、create view、带 into 的语句里;也不能和 holdlock、noholdlock 混用,混用时 ASE 会告警并忽略 at isolation。

步骤 5:把锁数调回合理值,并加兜底护栏


-- 先看 max memory 够不够,不够就先加大,再改锁数
sp_configure "max memory"
go
sp_configure "number of locks", 500000
go

-- 16.0 SP02 及以上:限制单会话锁数,超限中止事务而不是拖垮整库
sp_configure "allow resource limits", 1
go
-- 参数顺序以官方文档为准,典型写法:
sp_add_resource_limit null, "at all times", "lock_count", 200000, 2, 3, null
go


最后把监控挂上:每五分钟跑一次 sp_monitorconfig "number of locks" 落表,Pct_act 超过 80% 就告警;每周跑一次 sp_sysmon 00:10:00,看 Lock Management 那一节的 Lock Promotions 行,确认升级是真的在发生。

治理前后对比


指标治理前治理后
number of locks 配置2,000,000500,000
锁结构内存占用351MB88MB
锁峰值 Max_Used顶格 2,000,00046,000
Pct_act100%4.6%
1204 报错日均 26 次0
连接被拒日均 9 次0
批处理事务持锁11,400 个行锁1 个表锁
批处理耗时3 小时 18 分1 小时 52 分
max memory 余量800MB(已顶墙)2.6GB


批处理反而变快了,这个结果一开始团队不信。原因是行锁本身有维护成本,一万多个锁结构的申请、释放、哈希查找,比一次表锁贵得多。

四、实操检查清单


[*][ ] 用 sp_monitorconfig "number of locks" 确认当前 Pct_act 与 Max_Used,留一张基线表
[*][ ] 对高频批处理表执行 select lockscheme("表名"),确认是 datarows 还是 allpages
[*][ ] 用 monProcessActivity.LocksHeld 排序列出锁大户,确认是单会话还是全局并发
[*][ ] 统计批处理事务里的语句条数,凡是「一个事务几十条 DML」一律视为高风险
[*][ ] 所有批量 DELETE/UPDATE 改成 set rowcount N 或 top N 分批,N 取 500 到 1000
[*][ ] 分批循环里确认 @@rowcount = 0 才 break,避免死循环空转
[*][ ] 对批处理主力表执行 sp_setrowlockpromote "table" 下调阈值,参数组合写进变更单
[*][ ] 报表与巡检类查询统一加 at isolation 0 或 readpast,并在代码评审里卡住
[*][ ] 检查 max memory 与 total logical memory,保证前者大于后者,别踩 5861
[*][ ] 加锁数时按「每并发 20 个锁」起步估算,并预留并行查询的三分之一余量
[*][ ] 配置采样任务,Pct_act 超 80% 告警;不要把 1204 交给用户来发现
[*][ ] 变更后观察三个工作日,覆盖「白天高峰 + 夜间批处理」两个时段再收尾


几个容易踩的坑

一、以为「200 行以上就升级」。默认 LWM 和 HWM 都是 200,但计数是每条语句各自扫描会话重新计。一个事务六十条语句,单条最多 190 行,永远不升级。这个坑几乎每次都有人踩。

二、表级阈值覆盖数据库级,数据库级覆盖服务器级。更要留意的是存储位置:数据库级阈值存在 master..sysattributes,表级存在用户库的 sysattributes。dump/load 到新服务器后,表级阈值跟着库走,数据库级阈值不会跟过去。迁移完发现「阈值莫名失效」,多半是这个原因。

三、PCT 的分母是表行数,不是页数。行锁表的 PCT 是按行数算的,照抄页锁表的经验去算,会得到完全错误的预期。

四、升级失败不排队重试。白天有冲突锁,升级尝试直接失败,ASE 继续发行锁。所以「白天报错、夜里正常」并不是随机抖动,而是升级成功率在两个时段不同。

五、1204 之后不一定是报错,可能是挂住。锁耗尽后某些命令会 hang 在那儿不返回,连 dbcc 都挂(SAP Note 1966154 记录的正是这个现象)。这时候再怎么等也没用,只能定位并 kill 会话。

六、加锁数不看 max memory。锁数改大会同步增加逻辑内存需求,max memory 不够就报 5861,改不动。顺序必须是先加 max memory、再加锁数。

七、千万不要为了「省事」全局开隔离级别 2 或 3。级别 2 的共享锁持有到事务结束,级别 3 还要封扫描位置,锁量翻倍、并发塌方。只对确实需要的那几条语句用 holdlock 或 at isolation serializable。

八、改锁方案(alter table ... lock allpages)必须配 reorg rebuild,大表上是重量级操作。小热表值得改,大表改之前先算好停机窗口,别在业务高峰动手。

九、分批提交后忘了处理批与批之间的可见性。中途失败会留下半完成状态,需要水位字段做断点续跑,或者整批幂等重跑。
页: [1]
查看完整版本: Sybase ASE 锁升级与隔离级别治理实战:从 1204 锁耗尽到分批提交与升级阈值双线收敛