去年写的一篇blog,当时忘记发布了,今天又遇到同样的问题,突然想起曾经写过这么一篇文章,今天发布一下。
问题描述:
做N张销售订单,然后在应收账款->期间->销售更新->发票做开票动作,两个财务人员分别在不同的客户端做该动作。
真实的业务场景比如在月底统一开票,然后不同的财务负责处理不同的客户。
OK,死锁了。
问题重现:
由于这个bug是本地化SP2造成的,所以要确认Application Version是4.0.2501.128,总账->设置->参数设置 选中 中国式凭证系统。
然后用代码创建200张销售订单,打开两个客户端同时做应收账款->期间->销售更新->发票 动作,各做100张,嗯,这样差不多应该就死锁了。
问题原因:
通过查看SQL Server的死锁情况,发现NumberSequenceTable这张表被死锁了,可以通过如下存储过程查看死锁的进程:
create table #t(req_spid int,obj_name sysname)
declare @s nvarchar(4000)
,@rid int,@dbname sysname,@id int,@objname sysname
declare tb cursor for
select distinct req_spid,dbname=db_name(rsc_dbid),rsc_objid
from master..syslockinfo where rsc_type in(4,5)
open tb
fetch next from tb into @rid,@dbname,@id
while @@fetch_status=0
begin
set @s='select @objname=name from ['+@dbname+']..sysobjects where id=@id'
exec sp_executesql @s,N'@objname sysname out,@id int',@objname out,@id
insert into #t values(@rid,@objname)
fetch next from tb into @rid,@dbname,@id
end
close tb
deallocate tb
select 进程id=a.req_spid
,数据库=db_name(rsc_dbid)
,类型=case rsc_type when 1 then 'NULL 资源(未使用)'
when 2 then '数据库'
when 3 then '文件'
when 4 then '索引'
when 5 then '表'
when 6 then '页'
when 7 then '键'
when 8 then '扩展盘区'
when 9 then 'RID(行 ID)'
when 10 then '应用程序'
end
,对象id=rsc_objid
,对象名=b.obj_name
,rsc_indid
from master..syslockinfo a left join #t b on a.req_spid=b.req_spid
go
drop table #t
AX对表NumberSequenceTable的操作封装到了类NumberSeq等类中,所以我们只要看一下开票这个过程在哪里用到了这些类就应该可以找到问题的答案。
看了一下开票过程分配编码规则的地方,一共有两处:
1.SalesFormLetter->InsertJournal方法会调用allocateNumAndVoucher方法分配编码
2.LedgerVoucherObject类的Post方法。
1.的代码是SYS层的,由于不启用中国式凭证系统没有问题,所以问题应该是2.造成的。
看一下2.中post方法:
{
if (!ledgerVoucherTypeId_CN)
{
ledgerVoucherType = this.getVoucherType_CN();
if (!ledgerVoucherType)
throw error("@PCH10");
ledgerVoucherTypeId_CN = ledgerVoucherType.Id;
voucher_CN = NumberSeq_Voucher::newGetVoucherFromCode(ledgerVoucherType.NumberSequence,transDate).voucher();
}
for (more = ledgerTransList.first();
more;
more = ledgerTransList.next())
{
ledgerTransList.item().parmVoucher_CN(voucher_CN);
ledgerTransList.item().parmLedgerVoucherTypeId_CN(ledgerVoucherTypeId_CN);
if (ledgerTransList.item().parmTransDate() != oldTransDate)
{
oldTransDate = ledgerTransList.item().parmTransDate();
NumberSeq_Voucher::used(LedgerVoucherType_CN::find(ledgerVoucherTypeId_CN).NumberSequence,voucher_CN,oldTransDate);
}
}
}
其中
这一行代码最终会调用类NumberSeqget的NumInternal方法:
userConnection.ttsbegin();
sequenceUpdated = false;
numberSequenceTable.setConnection(userConnection);
select forupdate firstonly numberSequenceTable
index hint SeriesIdx
where numberSequenceTable.NumberSequence == _numberSequenceCode;
this.setCleanupSequence(numberSequenceTable);
ok = this.checkSetUpNum(numberSequenceTable);
if (ok)
{
_num = this.getNumFromList(userConnection,numberSequenceTable);
if (_num == '')
{
if (numberSequenceTable.NextRec == 0 || numberSequenceTable.NextRec > numberSequenceTable.Highest)
ok = checkFailed(strfmt("@SYS17478",numberSequenceTable.NumberSequence));
else
_num = this.getNumFromTable(userConnection,numberSequenceTable);
}
}
if (!ok)
{
userConnection.ttsabort();
throw error("@SYS25038");
}
userConnection.ttscommit();
可以看出这段代码用UserConnection开启了一个新的连接,这样做可以将编码分配的逻辑封装在一个单独的比较短的事务里,以免被封装到用户自己的ttsbegin和ttscommit中长时间地锁定NumberSequenceTable造成资源浪费和死锁.
问题出现在本地化代码的类NumberSeq_Voucher的Used方法,该方法对NumberSequenceTable进行了更新操作,但并没有将该操作用UserConnection隔离出一个事务,这样只有当最外层的ttsbegin对应的事务提交后才会释放对NumberSequenceTable表中相关记录的锁定,长时间地占用了NumberSequenceTable,这样外层的代码逻辑处理时间较长,很容易造成死锁。
解决方法:
1.改造一下NumberSeq_Voucher的Used方法,不要让它在代码开始的时候就select forupdate锁定NumberSequenceTable表,这样可以尽量减少死锁的几率;
2.将Used方法的调用与编码的分配的代码封装到一个单独的UserConnection里。
当然如果像如下写法就更是必死无疑了:
由于采用了悲观锁,第一个forupdate锁定了NumberSequenceTable的记录,只有等到ttscommit才会释放,于是在由UserConnection开启的一个事务里就痴痴地等着它释放这个锁,可第一个forupdate也够痴情的,它在等着UserConnection运行完,它好commit......
综上所述:对于采用了单独的UserConnection开启事务去处理的表要从一而终,不要再用ttsbegin和ttscommit去控制事务。SYS层的代码还是比较规矩的,没发现NumberSequenceTable脱离UserConnection在外面裸奔的情况。
个人愚见,如有误还望指点。