Ms sql MERGE INTO locks whole table for updates

I have a table for statistic values, it holds millions of records


The clustered index goes fragmeted to 90+% in a day or so.

Look at your clustered index, its key is 48 bytes long, it's not a good choice because your table is big enough and you have also 5 nonclustered indexes. All of them have these 48 bytes at every index level, so every nonclustered index occupies at least twice of space it needs.

IMHO, the first thing to do is, if possible, to change clustered index key, your clustered index can be defined on identity, it will be unique, always increasing, narrow, and this will reduce yor clustered index fragmentation, and in case when JsonData field is never updated clustered index fragmentation will be 0.

This will also decrease your insert time: now too much time is spent to log page slits caused by insert into clustered index.

To your second problem: lock escalation. As you said, every batch contains 2000 rows in the source table, but they cause 3402 rows to be inserted(according to estimated plan), and this is only for clustered index. You have 5 nonclustered indexes, so in one statement you insert at least 6 * 2000 = 12000 rows, or maybe all 20412 rows if the estimations are correct.

Lock escalation triggers on 5000 locks per statement:

In addition to escalating locks when an instance-wide threshold is crossed, SQL Server will also escalate locks when any individual session acquires more than 5,000 locks in a single statement. In this case, there is no randomness in choosing which session will get its locks escalated; it is the session that acquired the locks.

and in your case they very probably are row locks, this is because of your clustered index key that is random. It could take page locks in case of insertion into always increasing key, but your clustered key is really random. And in any case insertions into nonclustered indexes are random too, so it's normal that server chose row locks.

So you can disable lock escalation on your table or split your batches in 1000 rows per batch or even less, this should be tested.

Here is a small repro in response on this comment:

inserts can't take locks (can't lock a resource that doesn't exist)

if object_id('dbo.t') is not null drop table dbo.t;
create table dbo.t(id int identity primary key, col1 varchar(10), col2 varchar(10));
create index ix_col1 on dbo.t(col1);
create index ix_col2 on dbo.t(col2);

begin tran
insert into dbo.t (col1, col2)
select top 1000 'aaa', 'bbb'
from sys.columns c1 cross join sys.columns c2;

select *
from sys.dm_tran_locks
where resource_type <> 'DATABASE'
      and request_session_id = @@spid
order by resource_associated_entity_id,

rollback tran;