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

本文涉及的产品
云原生数据库 PolarDB MySQL 版,Serverless 5000PCU 100GB
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
云数据库 RDS MySQL Serverless,0.5-2RCU 50GB
简介: 背景       某张表有一批记录,A用户说,这批记录是我要的,但是我只要一条,B用户也说,这批记录是我要的,我也只要一条。 是不是有点像一群男人去逛怡红院,妹子们都是目标,但是今晚只要一位,至于是谁暂时还不确定,虽然不需要抢,但是得锁单。

背景      

某张表有一批记录,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

相关实践学习
使用PolarDB和ECS搭建门户网站
本场景主要介绍基于PolarDB和ECS实现搭建门户网站。
阿里云数据库产品家族及特性
阿里云智能数据库产品团队一直致力于不断健全产品体系,提升产品性能,打磨产品功能,从而帮助客户实现更加极致的弹性能力、具备更强的扩展能力、并利用云设施进一步降低企业成本。以云原生+分布式为核心技术抓手,打造以自研的在线事务型(OLTP)数据库Polar DB和在线分析型(OLAP)数据库Analytic DB为代表的新一代企业级云原生数据库产品体系, 结合NoSQL数据库、数据库生态工具、云原生智能化数据库管控平台,为阿里巴巴经济体以及各个行业的企业客户和开发者提供从公共云到混合云再到私有云的完整解决方案,提供基于云基础设施进行数据从处理、到存储、再到计算与分析的一体化解决方案。本节课带你了解阿里云数据库产品家族及特性。
相关文章
|
4月前
|
自然语言处理 网络协议 应用服务中间件
记录一次问题的解决过程
记录一次问题的解决过程
|
8月前
|
消息中间件 数据采集 Kafka
每次join之后没有正确处理数据的重复或缺失情况
每次join之后没有正确处理数据的重复或缺失情况
85 1
|
9月前
|
存储 关系型数据库 MySQL
mysql百万级数量插入、删除、分组、分页、更新语句优化
mysql百万级数量插入、删除、分组、分页、更新语句优化
删除一段时间内的记录,关键在于删除时筛选条件确定删除范围
删除一段时间内的记录,关键在于删除时筛选条件确定删除范围
66 0
|
测试技术 数据库
LeetCode(数据库)- 每次访问的交易次数
LeetCode(数据库)- 每次访问的交易次数
182 0
集合或映射迭代过程进行删除或修改操作的时候会导致并发异常
集合或映射迭代过程进行删除或修改操作的时候会导致并发异常
125 0
集合或映射迭代过程进行删除或修改操作的时候会导致并发异常
|
存储 测试技术 开发工具
BSTestRunner增加历史执行记录展示和重试功能
之前对于用例的失败重试,和用例的历史测试记录存储展示做了很多的描述呢,但是都是基于各个项目呢,不方便使用,为了更好的使用,我们对这里进行抽离,抽离出来一个单独的模块,集成到BSTestRunner中,以后我们使用BSTestRunner直接就可以使用里面的失败重试和展示历史记录了。
BSTestRunner增加历史执行记录展示和重试功能
|
Java Maven
个人向mavan使用过程中的问题记录
云栖号资讯:【点击查看更多行业资讯】在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来! 大纲 在初步会使用maven的POM文件配置后, 实际运用时会出现从来没见过的问题. 纪录两个自己学习过程中出现的两个问题.
个人向mavan使用过程中的问题记录