作者
digoal
日期
2023-08-05
标签
PostgreSQL , PolarDB , 队列 , 锁 , hash mod , advisory lock , cte , update limit , delete limit , vacuum , index , IO浪费 , CPU浪费
背景
在电商业务中可能涉及这样的场景, 由于有上下游关系的存在, 1、用户下单后, 上下游厂商会在自己系统中生成一笔订单记录并反馈给对方, 2、在收到反馈订单后, 本地会先缓存反馈的订单记录队列, 3、然后后台再从缓存取出订单并进行处理.
这个过程的核心流程: 高速写入队列、从队列按先后顺序提取并高速处理、从队列清除已处理订单记录.
如果是高并发的处理, 因为大家都按一个顺序获取, 容易产生热点, 可能遇到取出队列遇到锁冲突瓶颈、IO扫描浪费、CPU计算浪费的瓶颈. 以及在清除已处理订单后, 索引版本未及时清理导致的回表版本判断带来的IO浪费和CPU运算浪费瓶颈等.
- 文末的《打车与宇宙大爆炸的关系》一文有相似问题和优化方法, 思路类似.
本文将给出“队列处理业务的数据库性能优化”优化方法和demo演示. 性能提升10到20倍.
想体验一下的同学, 也可以通过云起实验启动环境来进行体验, 这个实验室是永久免费的.
DEMO
1、测试环境
MacBook Pro (15-inch, 2018) 2.2 GHz 六核Intel Core i7 32 GB 2400 MHz DDR4 PostgreSQL 15.1
因为是macos, 可能需要设置一下ulimit.
ulimit -n 1000000
2、上游写入订单处理队列表
create table t_order_q ( id serial8 primary key, -- 自增主键 order_id uuid unique, -- 上游传递过来的订单号 cts timestamp not null -- 上游传递过来的订单创建时间 ); -- create index on t_order_q (cts); -- 如果按订单时间先后取出处理, 则需要创建时间字段索引. 也可以按自增主键顺序处理, 则不需要时间索引.
3、取出并处理后的订单状态表
create table t_order_u ( id serial8 primary key, -- 自增主键 order_id uuid unique, -- 上游传递过来的订单号 cts timestamp not null, -- 上游传递过来的订单创建时间 uts timestamp not null, -- 订单处理时间 status int not null -- 订单处理状态标记 );
4、写入100万条订单队列
insert into t_order_q (order_id, cts) select gen_random_uuid(), clock_timestamp() from generate_series(1,1000000);
5、写pgbench压测脚本, 从队列取出, 并且使用ad lock对队列ID加事务锁, 判断是否正在处理, 事务结束自动释放ad lock. ad lock也经常被用于秒杀场景泄压.
vi t.sql with tmp as (delete from t_order_q where ctid = (select ctid from t_order_q where pg_try_advisory_xact_lock(id) order by id limit 1) returning order_id, cts) insert into t_order_u (order_id,cts,uts,status) select tmp.order_id, tmp.cts, now(), 1 from tmp; 或 begin; select id as v_id from t_order_q where pg_try_advisory_xact_lock(id) order by id limit 1 \gset with tmp as (delete from t_order_q where id = :v_id returning order_id, cts) insert into t_order_u (order_id,cts,uts,status) select tmp.order_id, tmp.cts, now(), 1 from tmp; end; 或(sleep 模拟应用拿到需要处理的订单后的应用端操作增加的耗时.) begin; select id as v_id from t_order_q where pg_try_advisory_xact_lock(id) order by id limit 1 \gset \sleep 10ms with tmp as (delete from t_order_q where id = :v_id returning order_id, cts) insert into t_order_u (order_id,cts,uts,status) select tmp.order_id, tmp.cts, now(), 1 from tmp; end;
6、压测256个并发消耗队列, 平均每个连接处理3906个事务.
select 1000000/256.0; 3906.2500000000000
7、压测结果
pgbench -M extended -f ./t.sql -n -r -P 1 -c 256 -j 2 -t 3906
transaction type: ./t.sql scaling factor: 1 query mode: extended number of clients: 256 number of threads: 2 maximum number of tries: 1 number of transactions per client: 3906 number of transactions actually processed: 999936/999936 number of failed transactions: 0 (0.000%) latency average = 8.111 ms latency stddev = 5.376 ms initial connection time = 429.698 ms tps = 25379.081141 (without initial connection time) statement latencies in milliseconds and failures: 8.114 0 with tmp as
未优化前的性能如何?
1、写pgbench压测脚本, 从队列取出, 并且使用ad lock对队列ID加事务锁, 判断是否正在处理, 事务结束自动释放ad lock. ad lock也经常被用于秒杀场景泄压.
vi t1.sql begin; select id as vid from t_order_q order by id for update limit 1 \gset with tmp as (delete from t_order_q where id = :vid returning order_id, cts) insert into t_order_u (order_id,cts,uts,status) select tmp.order_id, tmp.cts, now(), 1 from tmp; end;
2、压测结果
pgbench -M extended -f ./t1.sql -n -r -P 1 -c 256 -j 2 -t 3906
TPS 约 1200.
增加了skip locked后, TPS也只能到2500左右. 降低并发后使用skip locked性能可提升到8K tps左右.
begin; select id as vid from t_order_q order by id for update skip locked limit 1 \gset with tmp as (delete from t_order_q where id = :vid returning order_id, cts) insert into t_order_u (order_id,cts,uts,status) select tmp.order_id, tmp.cts, now(), 1 from tmp; end;
还有什么可以提升性能的点?
1、减少浪费的IO和cpu计算:
- 在并发的情况下, order by id limit 1需要扫描若干行, 而不是1行, 因为可能有些ID已经被ad lock touch了, 浪费的pg_try_advisory_xact_lock() cpu ops计算次数约等于 n + n-1 + n-2 + ... + n-n, 浪费的IO约等于N.
优化方法:
- 固定N个链接, 按ID hash mod 取不同的数据分片, 从而减少浪费的IO和cpu计算.
- 或者将队列表拆分成几个分区表, 入库的时候 按id hash mode, 每个分区分配给不同的进程取数, 从而减少冲突和浪费的扫描提高并发.
2、提高index vacuum的频率, 减少因没有index version导致的垃圾数据判断带来的cpu和回表的IO浪费. 提升autovacuum_work_mem, 容纳下所有dead tuple ctid避免多次扫描index.
优化方法:
- 配置参数autovacuum_naptime、autovacuum_work_mem(或者老版本 maintenance_work_mem)即可.
3、使用并行vacuum, 配置max_parallel_maintenance_workers.
4、配置vacuum使用prefetch blocks, 减少io delay带来的vacuum 比较久的问题. (适合 单次IO delay较高, 但是吞吐没有瓶颈的云盘)
5、一次取出多条, 批量处理.
6、使用IOPS较高, 单次IO delay较低的本地nvme SSD.
更多请参考末尾文章.
参考
《DB吐槽大会,第69期 - PG 不支持update | delete limit语法》
《在PostgreSQL中实现update | delete limit - CTID扫描实践 (高效阅后即焚)》
《PostgreSQL skip locked与CTE妙用 - 解决并发批量更新锁冲突带来的锁等待,提高处理吞吐》
《PostgreSQL 秒杀4种方法 - 增加 批量流式加减库存 方法》
《HTAP数据库 PostgreSQL 场景与性能测试之 30 - (OLTP) 秒杀 - 高并发单点更新》
《PostgreSQL 垃圾回收参数优化之 - maintenance_work_mem , autovacuum_work_mem》