PostgreSQL 全局ID分配(数据字典化)服务 设计实践

5 minute read

背景

设计一个全局ID分配服务,要求:

功能性要求

1. 输入字符串 返回 ID

i. 已经存在系统中的字符串返回原ID 【严格要求】

ii. 新字符串分配新ID 递增分配

2. 输入ID 返回字符串

3. 每个Topic一个ID序列

4. Topic可以动态增删

5. 支持hsf调用

性能要求

1. 支持 100万QPS以上的读访问

2. 支持批量 双向查询读操作,一次批量100的查询延时在1ms

3. 支持1万QPS左右的写操作

4. 支持批量写操作 ,一次批量100的写延时在10ms

系统性要求

1. 稳定可靠

2. 数据完全一致

3. 数据永远不丢

4. ID从0开始递增,尽量少空洞 【空洞占比少于 1%】

5. 灾难自恢复

实际上在之前,我有写过另一个任务分配系统的设计。

《PostgreSQL 高并发任务分配系统 实践》

另一方面,如果业务要求完全无缝的自增ID,我也有对应的文档提及。

《PostgreSQL 无缝自增ID的实现 - by advisory lock》

那么接下来按本文开头提到的几个要求进行设计。

设计一、含组ID,每个组一个序列,序列和文本在单个组内唯一

1、设计一个UDF,自动生成与组ID一对一的序列,并返回序列的值。

create or replace function get_per_gp_id(    
  text,   -- 序列名前缀  
  int     -- 分组ID,作为序列名后缀  
) returns int8 as $$    
declare    
begin    
  return nextval(($1||$2)::regclass);    
  exception when others then    
    execute 'create sequence if not exists '||$1||$2||' start with 0 minvalue 0' ;    
    return nextval(($1||$2)::regclass);    
end;    
$$ language plpgsql strict;    

2、创建测试表

create table tbl1(    
  gid int,   -- 分组ID  
  ts text,   -- 文本  
  sn int8,   -- 自增序列值  
  unique(gid,ts),     
  unique(gid,sn)    
);    

3、创建一个UDF,当输入组ID和文本时,如果文本存在,返回已有的序列,如果文本不存在则分配一个唯一ID,并返回这个ID。

create or replace function ins1(    
  int,   -- 分组ID  
  text   -- 文本  
) returns int8 as $$    
declare    
  res int8;    
begin    
  -- 查看该分组内该文本是否已存在  
  select sn into res from tbl1 where gid=$1 and ts=$2;    
  if found then    
    return res;    
  else    
    -- 不存在,则生成一个ID  
    insert into tbl1 (gid,ts,sn) values ($1, $2, get_per_gp_id('seq_', $1)) returning sn into res;    
    return res;    
  end if;    
  exception when others then    
    -- 异常则可能是其他并行会话正在生成该序列,重新查询,并返回SN。  
    select sn into res from tbl1 where gid=$1 and ts=$2;    
    if found then    
      return res;    
    else     
      raise ;    
    end if;    
end;    
$$ language plpgsql strict;    

设计二、不含组ID,文本和序列全局唯一

1、创建一个序列即可

create sequence seq_tbl2_sn start with 0 minvalue 0;    

2、创建测试表

create table tbl2(    
  ts text unique,   -- 文本  
  sn int8 default nextval('public.seq_tbl2_sn'::regclass) unique  -- 序列  
);    

3、创建一个UDF,当输入文本时,如果文本已存在,返回文本对应的序列,如果文本不存在,则分配一个唯一序列值,同时返回该值。

create or replace function ins2(    
  text    
) returns int8 as $$    
declare    
  res int8;    
begin    
  -- 查看该文本是否已存在  
  select sn into res from tbl2 where ts=$1;    
  if found then    
    return res;    
  else    
    -- 不存在,则生成一个ID  
    insert into tbl2 (ts) values ($1) returning sn into res;    
    return res;    
  end if;    
  exception when others then    
    -- 异常则可能是其他并行会话正在生成该序列,重新查询,并返回SN。  
    select sn into res from tbl2 where ts=$1;    
    if found then    
      return res;    
    else     
      raise ;    
    end if;    
end;    
$$ language plpgsql strict;    

设计三、含组ID,并且全局唯一

我们假设字典空间为40亿,则使用INT4。

如果字典空间超过40亿,则需要使用INT8。

1、创建序列,设置起始值为INT4的最小值

create sequence seq_tbl_dict minvalue -2147483648 start with -2147483648;  

2、创建测试表

create table tbl_dict(    
  gid int2,    -- 组ID  
  ts text,     -- 文本  
  sn int4 default nextval('public.seq_tbl_dict'::regclass),  -- 序列  
  unique (gid,ts),  
  unique (sn)  
);    

3、创建一个UDF,当输入文本时,如果文本已存在,返回文本对应的序列,如果文本不存在,则分配一个唯一序列值,同时返回该值。

create or replace function get_sn(int2, text) returns int as $$  
declare  
  res int;  
begin    
  -- 乐观查询  
  select sn into res from tbl_dict where gid=$1 and ts=$2;   
  if found then   
    return res;   
  end if;  
  
  -- 如果没有查到,则插入  
  insert into tbl_dict values($1,$2,nextval('public.seq_tbl_dict'::regclass)) on conflict (gid,ts) do nothing returning sn into res;  
  if found then  
    return res;  
  -- 如果插入冲突,则继续查询返回sn  
  else  
    select sn into res from tbl_dict where gid=$1 and ts=$2;  
    return res;  
  end if;  
end;  
$$ language plpgsql strict;  

批量操作用法

select ins1(gid, ts) from (values (),(),.....()) as t(gid, ts);    
    
select ins2(ts) from (values (),(),.....()) as t(ts);    

例子与性能,分配100条文本的ID约2毫秒

select ins1(id, 'test'||id) from generate_series(1,100) t(id);    
    
...........    
       0    
(100 rows)    
    
Time: 1.979 ms    

写操作压测

1、包括组ID

vi test1.sql    
\set gid random(1,10)    
\set ts random(1,100000000)    
select ins1(:gid, md5(:ts::text));    
pgbench -M prepared -n -r -P 1 -f ./test1.sql -c 56 -j 56 -T 120    
    
transaction type: ./test1.sql    
scaling factor: 1    
query mode: prepared    
number of clients: 56    
number of threads: 56    
duration: 120 s    
number of transactions actually processed: 18082960    
latency average = 0.232 ms    
latency stddev = 0.517 ms    
tps = 150680.114138 (including connections establishing)    
tps = 150687.227354 (excluding connections establishing)    
statement latencies in milliseconds:    
         0.001  \set gid random(1,10)    
         0.000  \set ts random(1,100000000)    
         0.230  select ins1(:gid, md5(:ts::text));    

2、不包括组ID

vi test2.sql    
\set ts random(1,100000000)    
select ins2(md5(:ts::text));    
pgbench -M prepared -n -r -P 1 -f ./test2.sql -c 56 -j 56 -T 120    
    
transaction type: ./test2.sql    
scaling factor: 1    
query mode: prepared    
number of clients: 56    
number of threads: 56    
duration: 120 s    
number of transactions actually processed: 11515008    
latency average = 0.584 ms    
latency stddev = 0.766 ms    
tps = 95613.170828 (including connections establishing)    
tps = 95618.249995 (excluding connections establishing)    
statement latencies in milliseconds:    
         0.001  \set ts random(1,100000000)    
         0.582  select ins2(md5(:ts::text));    

3、包括组ID,且全局唯一

vi test3.sql    
\set gid random(1,10)    
\set ts random(1,100000000)    
select get_sn(:gid, md5(:ts::text));    
pgbench -M prepared -n -r -P 1 -f ./test3.sql -c 56 -j 56 -T 120    
    
transaction type: ./test3.sql  
scaling factor: 1  
query mode: prepared  
number of clients: 56  
number of threads: 56  
duration: 120 s  
number of transactions actually processed: 7665708  
latency average = 0.877 ms  
latency stddev = 0.666 ms  
tps = 63868.058538 (including connections establishing)  
tps = 63875.166407 (excluding connections establishing)  
script statistics:  
 - statement latencies in milliseconds:  
         0.001  \set gid random(1,10)    
         0.000  \set ts random(1,100000000)    
         0.875  select get_sn(:gid, md5(:ts::text));  
postgres=# select * from tbl_dict  limit 10;
 gid |                ts                |     sn      
-----+----------------------------------+-------------
   9 | 8021bdb598f73577a063b50bdf0cef31 | -2147483648
   3 | e1988c3c7a80dcd1b1c1bdcf2ac31fe7 | -2147483646
   7 | 6ee09b73df8ae9bb97a4ebd4c51bd212 | -2147483647
   1 | fa8303da6ea2b6e995a1e090fb9cd9f2 | -2147483645
   7 | ca1c614104f1ad3af92d8d9a2911a5b6 | -2147483643
   8 | 4641dd1162f46e8be5f643facc85df94 | -2147483644
   6 | 88250e10f0d27cdebbf5c5eb4a7032a3 | -2147483641
   2 | 5718da726fd20d8fd12d56e9bf2d7e9e | -2147483642
   1 | 687e553016fe6bd1dba3ca6126b8b5b8 | -2147483639
  10 | a4707645d604dd1ad9ba96ff303cf9d9 | -2147483638
(10 rows)

空洞比例

空洞来源,序列不可逆转的使用。即使事务失败,耗费掉的序列值也不可能被返回。

实测符合要求。

postgres=# select gid,count(*),min(sn),max(sn),((max(sn)+1)/count(*)::float8-1)*100||' %' from tbl1 group by gid;    
 gid |  count  | min |   max   | ?column?     
-----+---------+-----+---------+----------    
   1 | 1790599 |   0 | 1790598 | 0 %    
   2 | 1793384 |   0 | 1793383 | 0 %    
   3 | 1791533 |   0 | 1791532 | 0 %    
   4 | 1792755 |   0 | 1792754 | 0 %    
   5 | 1793897 |   0 | 1793896 | 0 %    
   6 | 1794786 |   0 | 1794785 | 0 %    
   7 | 1792282 |   0 | 1792281 | 0 %    
   8 | 1790630 |   0 | 1790629 | 0 %    
   9 | 1791303 |   0 | 1791302 | 0 %    
  10 | 1790307 |   0 | 1790306 | 0 %    
(10 rows)    
    
postgres=# select count(*),min(sn),max(sn),((max(sn)+1)/count(*)::float8-1)*100||' %' from tbl2;    
  count   | min |   max    |        ?column?            
----------+-----+----------+------------------------    
 10877124 |   0 | 10877128 | 4.59680334685686e-05 %    
(1 row)    

读操作压测

只要写满了,就只是返回SN,所以只要略微修改一下压测脚本

vi test1.sql    
\set gid random(1,10)    
\set ts random(1,10000)    
select ins1(:gid, md5(:ts::text));    
    
    
pgbench -M prepared -n -r -P 1 -f ./test1.sql -c 56 -j 56 -T 120    
    
transaction type: ./test1.sql    
scaling factor: 1    
query mode: prepared    
number of clients: 56    
number of threads: 56    
duration: 120 s    
number of transactions actually processed: 69229025    
latency average = 0.097 ms    
latency stddev = 0.040 ms    
tps = 574906.288558 (including connections establishing)    
tps = 575063.117108 (excluding connections establishing)    
statement latencies in milliseconds:    
         0.002  \set gid random(1,10)    
         0.001  \set ts random(1,10000)    
         0.098  select ins1(:gid, md5(:ts::text));    

或者你可以换成SELECT

vi test3.sql    
\set gid random(1,10)    
\set ts random(1,10000)    
select * from tbl1 where gid=:gid and ts=md5(:ts::text);    
    
pgbench -M prepared -n -r -P 1 -f ./test3.sql -c 56 -j 56 -T 120    
    
transaction type: ./test3.sql    
scaling factor: 1    
query mode: prepared    
number of clients: 56    
number of threads: 56    
duration: 120 s    
number of transactions actually processed: 90985807    
latency average = 0.074 ms    
latency stddev = 0.009 ms    
tps = 758067.503368 (including connections establishing)    
tps = 758109.672642 (excluding connections establishing)    
statement latencies in milliseconds:    
         0.001  \set gid random(1,10)    
         0.001  \set ts random(1,10000)    
         0.074  select * from tbl1 where gid=:gid and ts=md5(:ts::text);    

数据库本身没有做任何优化,同时使用了ECS虚拟机环境,还有一定的性能提升空间。或者可以按GID拆成多个库,实现100万QPS不是问题。

小结

使用PostgreSQL的UDF,序列等功能,可以实现本文开头要求的“全局ID分配服务”的设计。

同时本例用到的PG实例为ECS虚拟机实例,读性能相比物理机要差一倍左右,单机实现100万的读,在物理机下面是没有问题的。

如果考虑将来的扩展性,可以将GID分配到不同的实例上,实现横向扩展,做到单个PG实例100万,多个实例100万*N的读TPS。

另一个问题,为什么不使用hash函数来生成全局字典呢?不是更快么?原因还是全局唯一,HASH函数有冲突可能并不能保证全局唯一,即使是范围INT8的哈希函数,也不能保证一对一。(第二个原因是字典化与唯一值个数对应,不会造成值的空洞,因此可以选择更小的整型,例如本例就使用了INT4,更小的整型意味着更少的存储,更快的访问速度)。

Postgrespro在json中引入了内核层字典化,解决了需要业务层来关心字典化的问题。

《JSONB 压缩版本 ZSON》

参考

《PostgreSQL 单机3.9万亿/天(计数器、序列、自增)》

《PostgreSQL 无缝自增ID的实现 - by advisory lock》

《PostgreSQL sharding有序UUID最佳实践 - serial global uuid stored in 64bit int8》

《PostgreSQL 优化CASE - 无序UUID性能问题诊断》

《PostgreSQL 高并发任务分配系统 实践》

Flag Counter

digoal’s 大量PostgreSQL文章入口