随机记录并发查询与更新(转移、删除)的"无耻"优化方法

    xiaoxiao2026-03-16  5

    背景      

    某张表有一批记录,A用户说,这批记录是我要的,但是我只要一条,B用户也说,这批记录是我要的,我也只要一条。

    是不是有点像一群男人去逛怡红院,妹子们都是目标,但是今晚只要一位,至于是谁暂时还不确定,虽然不需要抢,但是得锁单。

    被动分配式,等着妈妈给你分一个。

    主动挑选式,主动到姑娘们群里挑,就涉及到锁单的问题了,一个妹子只能陪一位公子哦。  

    上面的例子可能不太适合未成年人,那么看看另一个形象的比喻,某处有一堆砖块,每块砖头都有一个唯一编号,然后一群小伙伴同时来取砖块(每人每次取1块),要求每个小伙伴拿到的砖块ID是随机的,并且要求以最快的方式将砖块取完。

    这次真的来搬砖了,来比一比谁的搬砖能力强吧。

    我们将问题转化一下,一块砖一个ID,作为一条记录存入数据库,假设我们有1000万块砖。有128个小伙伴同时来搬砖,怎么能以最快的速度,随机的把砖搬完呢?

    这个场景实际上有一个来头,某个群红包口令业务,由于该业务没有对接账务系统,没有用户ID也没有用户手机号,所以没法将领红包的资格做判定,为了防止任何人都能猜测口令的方式来领取红包,搞了一个批量生成随机口令的方法,发红包的时候从数据库取走一条(随机口令)。既要考虑随机,又要考虑用户体验,所以选择了8位数值(比较容易猜测),然后又要考虑高并发的发红包场景,所以还要求取值快。

    优化方法1

    理解了需求后,我们看看如何优化?

    考虑随机、并发还不够,因为数据要取走(转移到一个已消耗的表中),因此还需要考虑数据的收缩。

    比如PostgreSQL的堆表,末端的空数据块是可以被回收的,那么我们在设计的时候,如果能从末端开始取,是最好的。

    1. 插入时就让数据随机,而不是取时随机。

    创建测试表, 存放一堆唯一值.

    postgres=# create table tbl (id int); CREATE TABLE

    唯一值随机插入, 取数据时按照数据块倒序取出, 这么做的好处是vacuum时可以直接回收这部分空间.

    postgres=# select * from generate_series(1,10) order by random(); generate_series ----------------- 1 9 4 7 3 6 8 2 10 5 (10 rows) postgres=# \timing Timing is on.

    随机的插入1000万数据

    postgres=# insert into tbl select * from generate_series(1,10000000) order by random(); INSERT 0 10000000 Time: 42204.425 ms

    从数据来看 , 已经随机插入了.

    postgres=# select * from tbl limit 10; id --------- 9318426 4366165 4661718 8491396 9413591 9845650 8830805 999712 7944907 2487468 (10 rows)

    在ctid(行号)上创建索引, 取数据时使用这个索引, 倒序从最后的数据块开始取数据.

    postgres=# create index idx_tbl_ctid on tbl(ctid); CREATE INDEX Time: 18824.496 ms 9.x开始不支持对系统列创建索引,所以我们可以增加一个自增主键 drop table tbl; create table tbl (pk serial8 primary key, id int); insert into tbl (id) select * from generate_series(1,10000000) order by random();

    例如:

    postgres=# select ctid,* from tbl order by pk desc limit 5; ctid | pk | id ------------+----------+--------- (54054,10) | 10000000 | 2168083 (54054,9) | 9999999 | 5812175 (54054,8) | 9999998 | 1650372 (54054,7) | 9999997 | 2443217 (54054,6) | 9999996 | 3002493 (5 rows)

    为了防止多个进程重复取数据, 使用这种方法.

    postgres=# with t as(select pk from tbl order by pk desc limit 5) delete from tbl where pk in (select pk from t) returning *; pk | id ----------+--------- 9999997 | 2443217 9999999 | 5812175 10000000 | 2168083 9999996 | 3002493 9999998 | 1650372 (5 rows) DELETE 5

    测试并行取数据.

    测试方法, 将数据插入另一张表,表示数据从一张表搬运到另一张表。

    create table test(like tbl); postgres=# with t as(select pk from tbl order by pk desc limit 5), t1 as (delete from tbl where pk in (select pk from t) returning *) insert into test select * from t1 returning * ; pk | id ---------+--------- 9999993 | 5893249 9999995 | 6079644 9999994 | 1834403 9999992 | 3511813 9999991 | 7078819 (5 rows) INSERT 0 5 postgres=# select * from test; pk | id ---------+--------- 9999993 | 5893249 9999995 | 6079644 9999994 | 1834403 9999992 | 3511813 9999991 | 7078819 (5 rows)

    使用pgbench 测试, 16个并行取数据进程, 每次取5条.

    postgres@localhost-> vi test.sql with t as(select pk from tbl order by pk desc limit 5),t1 as (delete from tbl where pk in (select pk from t) returning *) insert into test select * from t1 returning * ;

    测试完成后, 查询test表, 看看有没有重复数据就知道这种方法是否靠谱了.

    性能见下 :

    transaction type: ./test.sql scaling factor: 1 query mode: prepared number of clients: 64 number of threads: 64 duration: 30 s number of transactions actually processed: 1053020 latency average = 1.819 ms latency stddev = 1.126 ms tps = 35083.102896 (including connections establishing) tps = 35149.046180 (excluding connections establishing) script statistics: - statement latencies in milliseconds: 1.821 with t as(select pk from tbl order by pk desc limit 5),t1 as (delete from tbl where pk in (select pk from t) returning *) insert into test select * from t1 returning * ;

    经查没有重复数据, 方法靠谱,搬砖成功

    postgres=# select count(*),count(distinct id) from test; count | count --------+-------- 143400 | 143400 (1 row)

    以上方法数据是从堆表的末端开始搬运的,所以表会随着搬运,autovacuum使之变小。

    但是实际上,以上QUERY有一个问题,select没有加锁,在delete时,可能已经被其他并发进程搬走了。竞争的问题也被掩盖了。

    为了改善这个问题,比如要求每次请求,必须搬走1块砖。那么需要加LIMIT 1 for update skip locked,这样能解决竞争的问题,但是无法解决重复扫描的问题。

    我们先看看效果

    postgres@localhost-> vi test.sql with t as(select pk from tbl order by pk desc limit 1 for update skip locked), t1 as (delete from tbl where pk in (select pk from t) returning *) insert into test select * from t1 returning * ; $ pgbench -M prepared -n -r -P 1 -f ./test.sql -c 64 -j 64 -T 30 progress: 1.0 s, 4646.7 tps, lat 12.035 ms stddev 32.066 progress: 2.0 s, 4106.0 tps, lat 15.782 ms stddev 40.525 progress: 3.0 s, 4173.0 tps, lat 15.440 ms stddev 37.953 progress: 4.0 s, 4077.0 tps, lat 15.336 ms stddev 38.641 progress: 5.0 s, 4138.0 tps, lat 15.869 ms stddev 41.051 progress: 6.0 s, 4173.0 tps, lat 14.902 ms stddev 41.100 progress: 7.0 s, 4189.9 tps, lat 15.673 ms stddev 41.540

    64个搬运工,每秒只能搬运4000条左右。

    因为他们中最差的那个询问了64块砖才拿到搬运这块砖头的所有权,只有第一个人,询问了1块砖就拿到了所有权。

    那么怎么优化呢? 如何让每个搬运工每次拿到的砖头ID都是随机的,并且没人和他抢。

    优化方法2

    如何拿到随机的记录是关键,PostgreSQL提供了一个随机访问接口tablesample,通过这个接口,可以随机访问数据(提供一个百分比1-100即可),注意随机访问的数据是在where过滤条件前,所以百分比太小的话,你可能会访问不到任何数据。

    目前支持两种随机采样方法,1. system,按块随机(整个数据块的记录被取出);2. BERNOULLI扫全表,按百分比返回随机记录;因此BERNOULLI比SYSTEM随机度更精准,但是SYSTEM的效率更高。

    create or replace function exchange_t(i_limit int8, sample_ratio real) returns setof tbl as $$ declare -- 总共搬几块砖 res_cnt int8 := i_limit; -- 抢到的砖块ID pk_arr int8[]; -- 这次搬了几块(极少情况, 可能有一些被别抢去了) tmp_cnt int8; -- 最多循环次数 max_cnt int := 16; begin loop -- 无耻的搬砖优化,通过PostgreSQL采样接口,随机取砖头 select array_agg(pk) into pk_arr from (select pk from tbl TABLESAMPLE SYSTEM (sample_ratio) limit res_cnt) t ; -- 或者 select array_agg(pk) into pk_arr from (select pk from tbl TABLESAMPLE BERNOULLI (sample_ratio) limit res_cnt) t ; if found then -- 搬砖,并返回已搬走的砖头ID return query with tmp as (delete from tbl where pk = any (pk_arr) returning *) insert into test select * from tmp returning *; -- 这次搬了几块砖,还需要搬几块 GET DIAGNOSTICS tmp_cnt = ROW_COUNT; -- raise notice 'tmp_cnt: %', tmp_cnt; res_cnt := res_cnt - tmp_cnt; -- raise notice 'res_cnt: %', res_cnt; end if; -- 如果搬完,返回 if (res_cnt <= 0) then return; end if; -- 防止无限循环 max_cnt := max_cnt - 1; if (max_cnt <=0 ) then return; end if; end loop; end; $$ language plpgsql strict; postgres=# select * from exchange_t(5, 0.1); NOTICE: tmp_cnt: 5 NOTICE: res_cnt: 0 pk | id -----+--------- 49 | 1035771 51 | 7966506 57 | 5967428 91 | 7405759 120 | 7764840 (5 rows)

    压测

    搬砖性能从4000提升到了将近9万。

    vi test.sql select * from exchange_t(1, 0.1); pgbench -M prepared -n -r -P 1 -f ./test.sql -c 64 -j 64 -T 30 transaction type: ./test.sql scaling factor: 1 query mode: prepared number of clients: 64 number of threads: 64 duration: 30 s number of transactions actually processed: 2677383 latency average = 0.714 ms latency stddev = 2.607 ms tps = 89200.726564 (including connections establishing) tps = 89417.041119 (excluding connections establishing) script statistics: - statement latencies in milliseconds: 0.717 select * from exchange_t(1, 0.1);

    场景2

    除了这个搬砖场景,还有一些其他场景也能使用类似方法,感谢万能的PostgreSQL。

    比如有一个场景初始化了一批账号ID,初始ID=0,每次有用户来注册时,将ID=0的记录修改为此次注册的用户ID,相当于消耗一条ID=0的记录。

    使用采样的方法可以优化这个场景,不过别急着套用,因为数据采样是在过滤条件之前发生的,所以当所有数据范围都是我们的目标数据是没问题的,但是如果你把目标数据和非目标数据混到一起,这种采样的方法就可能导致冗余扫描,如果采样比例低,甚至找不到目标数据。因此前面的搬砖场景,我们每次都把数据搬走,剩余的所有数据依旧是目标数据,所以不存在问题。

    那么了解了以上原理之后,第二个场景,我们也采样转移法,即申请ID的时候,将数据转移走,而不仅仅是UPDATE ID=NEWID的做法。

    例子

    初始表 create table tbl1(pk serial8 primary key, id int, info text, crt_time timestamp, mod_time timestamp); 转移表 create table tbl2(like tbl1); 初始数据1000万 insert into tbl1 (id, info, crt_time) select 0, 'test', now() from generate_series(1,10000000);

    函数

    create or replace function exchange_t(i_limit int8, sample_ratio real, i_id int, i_mod_time timestamp) returns setof tbl2 as $$ declare -- 总共搬几块砖 res_cnt int8 := i_limit; -- 抢到的砖块ID pk_arr int8[]; -- 这次搬了几块(极少情况, 可能有一些被别抢去了) tmp_cnt int8; -- 最多循环次数 max_cnt int := 16; begin loop -- 无耻的搬砖优化,通过PostgreSQL采样接口,随机取砖头 select array_agg(pk) into pk_arr from (select pk from tbl1 TABLESAMPLE SYSTEM (sample_ratio) limit res_cnt) t ; -- 或者 select array_agg(pk) into pk_arr from (select pk from tbl1 TABLESAMPLE BERNOULLI (sample_ratio) limit res_cnt) t ; if found then -- 搬砖,并返回已搬走的砖头ID return query with tmp as (delete from tbl1 where pk = any (pk_arr) returning pk,info,crt_time) insert into tbl2(pk,id,info,crt_time,mod_time) select pk,i_id,info,crt_time,i_mod_time from tmp returning *; -- 这次搬了几块砖,还需要搬几块 GET DIAGNOSTICS tmp_cnt = ROW_COUNT; -- raise notice 'tmp_cnt: %', tmp_cnt; res_cnt := res_cnt - tmp_cnt; -- raise notice 'res_cnt: %', res_cnt; end if; -- 如果搬完,返回 if (res_cnt <= 0) then return; end if; -- 防止无限循环 max_cnt := max_cnt - 1; if (max_cnt <=0 ) then return; end if; end loop; end; $$ language plpgsql strict;

    测试

    postgres=# select exchange_t(1,0.1,10,now()); exchange_t --------------------------------------------------------------------------- (360129,10,test,"2017-03-03 16:48:58.86919","2017-03-03 16:51:13.969138") (1 row) Time: 0.724 ms postgres=# select count(*) from tbl1; count --------- 9999997 (1 row) Time: 859.980 ms postgres=# select count(*) from tbl2; count ------- 3 (1 row) Time: 0.420 ms

    压测

    vi test.sql \set id random(1,10000000) select * from exchange_t(1::int8, 0.1::real, :id, now()::timestamp); pgbench -M prepared -n -r -P 1 -f ./test.sql -c 64 -j 64 -T 30 transaction type: ./test.sql scaling factor: 1 query mode: prepared number of clients: 64 number of threads: 64 duration: 30 s number of transactions actually processed: 2970824 latency average = 0.644 ms latency stddev = 0.348 ms tps = 98599.587185 (including connections establishing) tps = 98791.348808 (excluding connections establishing) script statistics: - statement latencies in milliseconds: 0.001 \set id random(1,10000000) 0.644 select * from exchange_t(1::int8, 0.1::real, :id, now()::timestamp);

    每秒转移9.8万记录,采样法消除冲突后性能惊人。

    postgres=# select count(*) from tbl1; count --------- 7029173 (1 row) postgres=# select count(*) from tbl2; count --------- 2970827 (1 row) postgres=# select * from tbl2 limit 10; pk | id | info | crt_time | mod_time --------+---------+------+---------------------------+---------------------------- 329257 | 10 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:51:01.261172 107713 | 10 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:51:08.012152 360129 | 10 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:51:13.969138 61065 | 7513722 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.669893 95337 | 4101700 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.672948 124441 | 7159045 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.673335 87041 | 1868904 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.671536 126617 | 4055074 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.673654 10201 | 3790061 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.673959 191081 | 6663554 | test | 2017-03-03 16:48:58.86919 | 2017-03-03 16:52:44.674014 (10 rows)

    小结

    1. 为了解决高并发的数据随机访问、更新、转移等热点与扫描相似悖论的问题,PostgreSQL 采样接口打开一种很"无耻"的优化之门,让小伙伴们可以开足并发,卯足玛丽开搞。

    为什么一个蛋糕,大家都要从一处抢呢,围成一圈,每人在各自的方向挖一勺不是更好么?就好像小时候长辈较我们夹菜,要夹靠近自己这一边的一样。

    参考

    https://www.postgresql.org/docs/9.6/static/plpgsql-statements.html

    https://wiki.postgresql.org/wiki/TABLESAMPLE_Implementation

    相关资源:python入门教程(PDF版)
    最新回复(0)