Paul van Oordt SQL Services
Freelance SQL Server specialist en troubleshooter, Utrecht / Antwerpen
0627400408 - WhatsApp - info@vanoordt.nl
LinkedIn - curriculum vitae
vanoordt.nl - English
nieuwsbrief
Zo nu en dan verzorg ik cursusdagen SQL Server programmeren en vaak werk ik dan samen met de klas het voorbeeld uit van een bancaire overboeking. Hieronder vind je de gehele uitwerking en een mooi staaltje van de performance die SQL Server haalt.
Wil je mijn nieuwsbrief niet ontvangen, laat het me weten.
Met hartelijke groet,
Paul van Oordt
PS Ik zag het al een tijdje aankomen, afgelopen september was ik 25 jaar zelfstandig. Wat een heerlijk vak heb ik toch gekozen. Ik ga nog wel even door!
De bancaire overboeking is hét standaard voorbeeld van een transactie: er is een afboeking en een bijboeking die beide of geen van beide moeten worden uitgevoerd. Verder is er een saldocheck, het vastleggen dat de overboeking is uitgevoerd en het uitstellen ervan als de saldocheck faalt. De concurrency issues zijn niet triviaal met een updatelock en serializable isolation. In een cursus is het een ideale begrijpelijke casus waarin zaken aan de orde komen als server-side programmeren, stored procedures, transacties, locking en error handling. En natuurlijk bij iedere query: performance en schaalbaarheid.
De uitwerking hieronder omvat drie tabellen: account, accountmutatie en overboeking. Naast de inserts en updates zijn er twee queries: het vinden van de uit te voeren overboekingen en (vooral) de saldocheck op basis van een beginsaldo in account en de accountmutaties. Beide queries zijn schaalbaar door een goede index.
Het verwerken van de overboekingen moet in een loop, want ze moeten stuk voor stuk slagen of falen. We krijgen dus één niet al te zware procedure die herhaald wordt uitgevoerd.
De code is bondig en begrijpelijk en zou goed moeten performen. Maar hoe goed?
Ik heb de tabellen gevuld met 10.000 rekeningen en een miljoen te verwerken overboekingen, leidend tot twee miljoen rijen in accountmutatie als alle saldochecks goed gaan.
De performance: 100.000 overboekingen in 25 seconden, 4000 per seconde dus, bij gemiddeld 100 te sommeren mutaties per account. Op mijn laptop (met netvoeding). Op één core. Zo snel is SQL Server!
De hele code inclusief testdata vindt je hieronder. En wil je samen met je collega's meer leren over het programmeren van SQL Server, kijk eens op mijn site: SQL Server crash course. Ik kom graag bij je langs.
Dank voor het lezen.
use master
go
create database banktest;
go
use banktest;
go
create table dbo.account
(reknr int primary key,
beginsaldo dec(18) not null default (0),
laatst_verwerkte_mutatie int not null default (0), -- in beginsaldo
minimaal_saldo dec(18) not null default (0));
go
create table dbo.overboeking
(id int identity not null primary key,
debreknr int not null references dbo.account (reknr),
credreknr int not null references dbo.account (reknr),
bedrag dec(18) not null,
vanafdatum date not null default (getUTCdate()),
uitgevoerd bit not null default (0));
go
-- index om de nog uit te voeren overboekingen te vinden
-- zeker als dit een kleine subset is
create index idx_overboeking_niet_uitgevoerd on dbo.overboeking (id)
include (debreknr, credreknr, bedrag, vanafdatum, uitgevoerd)
where uitgevoerd = 0
go
create table dbo.accountmutatie
(id int identity not null primary key,
overboeking int not null references overboeking (id),
reknr int not null references account (reknr),
bedrag dec(18) not null,
datumtijd datetime2 not null default (getUTCdate()));
go
-- index om het saldo te bepalen
-- deze index fragmenteert er er worden range-scans gedaan; zo nu en dan defragmenteren dus
create index idx_accountmutatie_reknr on dbo.accountmutatie (reknr, id) include (bedrag);
go
-- testdata
-- 10K rekeningen
with n0 as (select 1 as c union all select 1) -- 2 rows
,n1 as (select 1 as c from n0 as a cross join n0 as b) -- 4 rows
,n2 as (select 1 as c from n1 as a cross join n1 as b) -- 16 rows
,n3 as (select 1 as c from n2 as a cross join n2 as b) -- 256 rows
,n4 as (select 1 as c from n3 as a cross join n3 as b) -- 65536 rows
,nums as (select row_number() over(order by (select null)) as n from n4)
insert dbo.account (reknr, beginsaldo, minimaal_saldo)
select n, n%10*100, -n%10*100
from nums
where n <= 10000;
-- 1M uit te voeren overboekingen
with n0 as (select 1 as c union all select 1) -- 2 rows
,n1 as (select 1 as c from n0 as a cross join n0 as b) -- 4 rows
,n2 as (select 1 as c from n1 as a cross join n1 as b) -- 16 rows
,n3 as (select 1 as c from n2 as a cross join n2 as b) -- 256 rows
,n4 as (select 1 as c from n3 as a cross join n3 as b) -- 65536 rows
,n5 as (select 1 as c from n4 as a cross join n2 as b) --1048576 rows
,nums as (select row_number() over(order by (select null)) as n from n5)
insert dbo.overboeking (debreknr, credreknr, bedrag)
select (n%10000)+1, ((n+1)%10000)+1, n%100
from nums
where n <= 1000000;
go
-- inline table valued function voor actueel saldo en minimaal_saldo
create or alter function dbo.tvf_reksaldo (@reknr int) returns table
as
return (
select isnull(sum(AM.bedrag), 0)+A.beginsaldo saldo, A.minimaal_saldo
from dbo.account A
left join dbo.accountmutatie AM with (updlock)
on AM.reknr = A.reknr
and AM.id > A.laatst_verwerkte_mutatie
where A.reknr = @reknr
group by A.reknr, A.beginsaldo, A.minimaal_saldo, A.laatst_verwerkte_mutatie
);
go
-- procedure voor het verwerken van een enkele overboeking
create or alter proc dbo.usp_overboeking
@id int
,@debreknr int
,@credreknr int
,@bedrag dec(18)
as
begin try;
begin tran;
-- saldocheck
if exists
(select *
from dbo.tvf_reksaldo(@debreknr)
where saldo - @bedrag >= minimaal_saldo)
begin; -- voldoende saldo
-- bijboeken
insert dbo.accountmutatie (reknr, bedrag, overboeking)
values (@credreknr, @bedrag, @id);
-- afboeken
insert dbo.accountmutatie (reknr, bedrag, overboeking)
values (@debreknr, -@bedrag, @id);
-- statusupdate
update dbo.overboeking
set uitgevoerd = 1
where id = @id;
end;
else
begin; -- onvoldoende saldo, stel verwerking 1 dag uit
update dbo.overboeking
set vanafdatum = dateadd(dd, 1, getUTCdate())
where id = @id;
end;
commit tran;
end try
begin catch
rollback tran;
end catch;
go
-- verwerk batch van overboekingen
create or alter proc verwerk_overboekingen_serieel @aantal int
as
set nocount on;
set transaction isolation level serializable;
declare @id int
,@debreknr int
,@credreknr int
,@bedrag dec(18);
declare c_overboeking cursor forward_only for
select top (@aantal) id, debreknr, credreknr, bedrag
from dbo.overboeking
where uitgevoerd = 0
and vanafdatum <= getUTCdate()
order by id;
open c_overboeking;
fetch next from c_overboeking into @id, @debreknr, @credreknr, @bedrag;
while @@fetch_status = 0
begin;
exec dbo.usp_overboeking @id, @debreknr, @credreknr, @bedrag;
fetch next from c_overboeking into @id, @debreknr, @credreknr, @bedrag;
end;
close c_overboeking;
deallocate c_overboeking;
go
-- test batch van 100000
exec verwerk_overboekingen_serieel @aantal = 100000
-- resultaten
select count(*) from dbo.accountmutatie
select top 100 * from dbo.accountmutatie
select top 100 A.reknr, A.minimaal_saldo, A.beginsaldo, S.saldo
from account A
cross apply dbo.tvf_reksaldo(A.reknr) S
select top 100 * -- uitgestelde overboekingen
from overboeking
where vanafdatum > getUTCdate()
december 2022; de nieuwsbrief verschijnt enkele keren per jaar; aanmelden / afmelden