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

7 minute read

背景

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

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

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

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

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

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

pic

我们将问题转化一下,一块砖一个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都是随机的,并且没人和他抢。

pic

优化方法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万。

pic

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

Flag Counter

digoal’s 大量PostgreSQL文章入口