隨機記錄併發查詢與更新(轉移、刪除)的”無恥”優化方法

德哥發表於2017-03-03

背景      

某張表有一批記錄,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=# 	iming    
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


相關文章