Postgres int 数组与中介 table 来保存访问控制列表的用户 ID

Postgres int arrays vs a intermediary table to hold user ids for access control list

我即将为一个大型 Web 应用程序实施记录级别的安全性,我正在考虑是否采用旧的可信路线,即使用单独的 table 来保存对一个对象(行)的引用以及允许访问它的用户 ID,或者使用 PostgreSql (9.4+) int 数组并在数组中保存用户 ID 列表。因为我要处理数百万条记录,所以我想在性能方面做到这一点。

我认为它使用数组的方式是访问列表要么是 null/empty 用于所有用户允许的所有访问,要么包含多个用户 ID。数组的最大长度只能与公司的用户一样大(尽管如果允许所有用户 - 它也可能是 null/empty)。

后端由 Django/Python 提供支持,如果有任何区别的话,数据库目前是 PostgreSql 9.4(如果需要,我可以轻松升级到最新版本)。

在这个用例中什么会更高效?数组的想法对我很有吸引力,因为它看起来很简单,但我不想为此牺牲性能。

我亲自出去测试了一下。 我创建了一个简单的用户 table,只有一个序列号,一个分布了 1000 万行的记录 table 跨越 10K 用户和 5 个帐户。 记录 table 有一个帐户字段(多租户)和一个 integer[] 的 acl 列以及一个 单独访问 table 以限制对用户的访问。记录也有一个私有的布尔标志,它 确定记录是 public(对帐户)还是对 ACL 上的用户 + 用户私有。

记录中填充了 1000 万行和随机 ACL 列表 - 有些是空的,有些是可变长度的 值。假设用户可以看到一条记录,如果:

  • 它没有 ACL 并且不是私有的
  • 它有一个 ACL,但用户在其中

同样适用于 table 方法:如果用户存在于访问 table 中或者如果左连接为空 (未指定访问权限)且私有标志为 false,则允许访问。 私有标志是根据具有 empty/no ACL 的行随机填充的。

运行 下面的最后两个查询(在解释分析下)以及使用它们的更现实的变体 限制结果似乎是数组方法的明显赢家。当然,使用的数据 因为这两种方法在它分别随机创建两次的意义上并不相似,但我 假设有了这个数字行,我可以判断出足够接近的获胜者。

可以的话请大家批评指正测试一下

-- create the intarray extension.
create extension intarray;

-- Create users table. Just an id will do fot this test
create table users (
    id serial primary key
);

-- A records table. This one is controled by the ACL
create table records (
    id serial primary key,
    account integer,
    private bool default false,
    acl integer[]
);

-- access table to limit record access for users. this is the second option vs array.
create table access (
    record_id integer,
    user_id integer
);


-- generate 10000 users
insert into users
    select from generate_series(1,10000);


-- some indexes on both methods:
create index aclindex on records using gin(acl gin__int_ops);
create index accounts on records (account);
create index useraccess on access(record_id, user_id);


-- function to geneate random acls
DROP FUNCTION IF EXISTS make_acl();
CREATE FUNCTION make_acl() RETURNS integer[] AS $$
DECLARE
    acl integer[];
    count integer;
    rand integer;
    done bool;
BEGIN
    count := (trunc(random() * 9 ));
    WHILE count != 0 LOOP
        rand := (trunc(random() * 9999 + 1));
        acl := array_append(acl, rand);
        count := count -1;
    END LOOP;
    RETURN acl;
END;
$$ LANGUAGE PLPGSQL VOLATILE;
SELECT make_acl();

-- populate records table
insert into records(acl, account)
    SELECT make_acl(), (trunc(random() * 5 + 1))
    from generate_series(1,10000000);

-- set private randomly on all records without ACL, and true where acl exists
UPDATE records set private = (trunc(random() * 10 + 1)) > 5
WHERE acl is null;
update records set private = true where acl is not null;

-- populate access table
insert into access(record_id, user_id)
select  (trunc(random() * 99999 + 1)), (trunc(random() * 9999 + 1))
from generate_series(1, 10000000);

-- Select using access table
explain analyze
select records.id
from records
left join access on records.id=access.record_id
where records.account = 1
and ((records.private = true and (access.user_id = 25 or access.user_id is null)) 
or records.private = false);


-- Select using ACL array
explain analyze
select * from records
where account = 1
and ((private = true and (acl @> array [25] or acl is null)) or private = false);

结果:

中间 table 方法的结果:

                                                                   QUERY PLAN                                                                    
-------------------------------------------------------------------------------------------------------------------------------------------------
 Hash Right Join  (cost=275544.43..634615.43 rows=1933800 width=4) (actual time=1765.044..6152.780 rows=2093789 loops=1)
   Hash Cond: (access.record_id = records.id)
   Filter: ((records.private AND ((access.user_id = 25) OR (access.user_id IS NULL))) OR (NOT records.private))
   Rows Removed by Filter: 1878666
   ->  Seq Scan on access  (cost=0.00..183085.23 rows=9458923 width=8) (actual time=100.433..842.632 rows=10000000 loops=1)
   ->  Hash  (cost=243059.43..243059.43 rows=1980000 width=5) (actual time=1662.915..1662.915 rows=2001411 loops=1)
         Buckets: 16384  Batches: 32  Memory Usage: 2281kB
         ->  Bitmap Heap Scan on records  (cost=37065.43..243059.43 rows=1980000 width=5) (actual time=219.929..1349.931 rows=2001411 loops=1)
               Recheck Cond: (account = 1)
               Rows Removed by Index Recheck: 4713065
               Heap Blocks: exact=50052 lossy=105473
               ->  Bitmap Index Scan on accounts  (cost=0.00..36570.43 rows=1980000 width=0) (actual time=210.267..210.267 rows=2001411 loops=1)
                     Index Cond: (account = 1)
 Planning time: 0.283 ms
 Execution time: 6208.045 ms
(15 rows)

数组方法的结果:

                                                             QUERY PLAN                                                              
-------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on records  (cost=37053.90..247997.90 rows=1933853 width=48) (actual time=215.179..1769.639 rows=223663 loops=1)
   Recheck Cond: (account = 1)
   Rows Removed by Index Recheck: 4713065
   Filter: ((private AND ((acl @> '{25}'::integer[]) OR (acl IS NULL))) OR (NOT private))
   Rows Removed by Filter: 1777748
   Heap Blocks: exact=50052 lossy=105473
   ->  Bitmap Index Scan on accounts  (cost=0.00..36570.43 rows=1980000 width=0) (actual time=205.250..205.250 rows=2001411 loops=1)
         Index Cond: (account = 1)
 Planning time: 0.106 ms
 Execution time: 1775.743 ms
(10 rows)