如何在 Postgres 9.6+ 中生成长度为 N 的随机、唯一的字母数字 ID?

Posted

技术标签:

【中文标题】如何在 Postgres 9.6+ 中生成长度为 N 的随机、唯一的字母数字 ID?【英文标题】:How to generate a random, unique, alphanumeric ID of length N in Postgres 9.6+? 【发布时间】:2017-06-17 15:35:59 【问题描述】:

我在 *** 上看到了一堆 different solutions,它们跨越了很多年和许多 Postgres 版本,但是有一些较新的功能,例如 gen_random_bytes 我想再次询问是否有更新的更简单的解决方案版本。

给定的 ID 包含 a-zA-Z0-9,并且大小会根据它们的使用位置而有所不同,例如...

bTFTxFDPPq
tcgHAdW3BD
IIo11r9J0D
FUW5I8iCiS

uXolWvg49Co5EfCo
LOscuAZu37yV84Sa
YyrbwLTRDb01TmyE
HoQk3a6atGWRMCSA

HwHSZgGRStDMwnNXHk3FmLDEbWAHE1Q9
qgpDcrNSMg87ngwcXTaZ9iImoUmXhSAv
RVZjqdKvtoafLi1O5HlvlpJoKzGeKJYS
3Rls4DjWxJaLfIJyXIEpcjWuh51aHHtK

(如IDs that Stripe uses。)

在 Postgres 9.6+ 中,如何通过一种简单的方法为不同的用例指定不同的长度,从而随机且安全地生成它们(就减少冲突和降低可预测性而言)?

我认为理想情况下该解决方案的签名类似于:

generate_uid(size integer) returns text

size 可根据您自己的权衡来定制,以降低冲突的机会与减小字符串大小以提高可用性。

据我所知,它必须使用gen_random_bytes() 而不是random() 来实现真正的随机性,以减少被猜到的机会。

谢谢!


我知道 UUID 有 gen_random_uuid(),但我不想在这种情况下使用它们。我正在寻找能够为我提供类似于 Stripe(或其他)使用的 ID 的东西,看起来像:"id": "ch_19iRv22eZvKYlo2CAxkjuHxZ",它尽可能短,同时仍然只包含字母数字字符。

这个要求也是为什么encode(gen_random_bytes(), 'hex') 不太适合这种情况,因为它减少了字符集,从而迫使我增加字符串的长度以避免冲突。

我目前正在应用程序层执行此操作,但我希望将其移至数据库层以减少相互依赖性。以下是在应用层执行此操作的 Node.js 代码可能如下所示:

var crypto = require('crypto');
var set = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

function generate(length) 
  var bytes = crypto.randomBytes(length);
  var chars = [];

  for (var i = 0; i < bytes.length; i++) 
    chars.push(set[bytes[i] % set.length]);
  

  return chars.join('');

【问题讨论】:

N的范围是多少? ***.com/q/40006558/330315 或 ***.com/q/19530736/330315 @IanStorm。我回答了这个问题,因为我看到了很多。但是,实际上我认为它不应该在这里使用“唯一标识符”一词。如果你想要胡言乱语,你可以拥有它,无论如何。但是标识符而不是 UUID 非常愚蠢,恕我直言。这就是它的用途。 感谢@EvanCarroll!我使用术语“标识符”是因为那是我的用例,但更重要的是因为我认为它意味着必要的安全性——结果不应该是可预测的,类似于使用 SERIAL 在这种情况下不起作用。我知道 UUID 是为此而设计的,但我希望对输出长度和“外观”有更多的控制,就所使用的字符而言——类似于 Youtube 或其他人为短 URL 代码所做的事情。 @kevlarr 如果62**10 的熵永远不够。这就是伊恩正在做的事情。他将 10 个字节存储在 14 个字节的存储空间中,用于62**10 位熵。当他可以在 16 个字节中拥有 2**128 bits 时(碰撞的可能性大大降低,作为标准,这是你这样做的方式),或者他可以使用具有 0 碰撞机会并返回较小密钥的盐渍哈希猫 【参考方案1】:

想通了,这里有一个函数:

CREATE OR REPLACE FUNCTION generate_uid(size INT) RETURNS TEXT AS $$
DECLARE
  characters TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  bytes BYTEA := gen_random_bytes(size);
  l INT := length(characters);
  i INT := 0;
  output TEXT := '';
BEGIN
  WHILE i < size LOOP
    output := output || substr(characters, get_byte(bytes, i) % l + 1, 1);
    i := i + 1;
  END LOOP;
  RETURN output;
END;
$$ LANGUAGE plpgsql VOLATILE;

然后简单地运行它:

generate_uid(10)
-- '3Rls4DjWxJ'

警告

执行此操作时,您需要确保您创建的 ID 的长度足以避免随着时间的推移随着您创建的对象数量的增加而发生冲突,这可能是违反直觉的,因为 Birthday Paradox . 因此,对于任何合理常见的创建对象,您可能希望长度大于(或远大于)10,我只是使用10 作为一个简单示例。


用法

定义函数后,您可以在表定义中使用它,如下所示:

CREATE TABLE collections (
  id TEXT PRIMARY KEY DEFAULT generate_uid(10),
  name TEXT NOT NULL,
  ...
);

然后在插入数据的时候,像这样:

INSERT INTO collections (name) VALUES ('One');
INSERT INTO collections (name) VALUES ('Two');
INSERT INTO collections (name) VALUES ('Three');
SELECT * FROM collections;

它会自动生成id 值:

    id     |  name  | ...
-----------+--------+-----
owmCAx552Q | ian    |
ZIofD6l3X9 | victor |

带前缀的用法

或者,您可能想在查看日志或调试器中的单个 ID 时添加一个前缀以方便查看(类似于 how Stripe does it),如下所示:

CREATE TABLE collections (
  id TEXT PRIMARY KEY DEFAULT ('col_' || generate_uid(10)),
  name TEXT NOT NULL,
  ...
);

INSERT INTO collections (name) VALUES ('One');
INSERT INTO collections (name) VALUES ('Two');
INSERT INTO collections (name) VALUES ('Three');
SELECT * FROM collections;

      id       |  name  | ...
---------------+--------+-----
col_wABNZRD5Zk | ian    |
col_ISzGcTVj8f | victor |

【讨论】:

这太好了,谢谢伊恩! — 您是否使用随机字符串作为主键没有问题?还是有其他需要注意的问题? 如何确保gen_random_bytes(size); 是唯一的; 如果你用它来定义一个默认值,并发插入安全吗? @kcstricks 是的,变量对于每个函数调用都是本地的 @hjl 如果size 很小并且您担心碰撞的机会,请确保您的列是PRIMARY KEYUNIQUE 然后您可以重试(从而生成一个新ID ) 如果您收到重复键错误。如果size 足够大,那么它不会在您(或您的程序或宇宙)的生命周期中发生。【参考方案2】:

我正在寻找能够为我提供“短代码”(类似于 Youtube 用于视频 ID 的内容)的东西,它们尽可能短,同时仍然只包含字母数字字符。

这是一个与您最初提出的问题完全不同的问题。那么你想要的就是在桌子上放一个serial类型,并使用hashids.org code for PostgreSQL。

这将返回 1:1 和唯一编号(序列号) 绝不重复或有可能发生冲突。 也是base62 [a-zA-Z0-9]

代码看起来像这样,

SELECT id, hash_encode(foo.id)
FROM foo; -- Result: jNl for 1001

SELECT hash_decode('jNl') -- returns 1001

这个模块也支持盐。

【讨论】:

【参考方案3】:

审查,

    [a-z] 中的 26 个字符 [A-Z] 中的 26 个字符 [0-9] 中的 10 个字符 [a-zA-Z0-9] (base62) 中的 62 个字符 substring(string [from int] [for int]) 函数看起来很有用。

所以它看起来像这样。首先,我们证明我们可以获取随机范围并从中提取。

SELECT substring(
  'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
  1, -- 1 is 'a', 62 is '9'
  1,
);

现在我们需要一个介于163 之间的范围

SELECT trunc(random()*62+1)::int+1
FROM generate_series(1,1e2) AS gs(x)

这让我们到达那里..现在我们只需加入两者..

SELECT substring(
  'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
  trunc(random()*62)::int+1
  1
)
FROM generate_series(1,1e2) AS gs(x);

然后我们将其包装在ARRAY constructor (because this is fast)

SELECT ARRAY(
  SELECT substring(
    'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
    trunc(random()*62)::int+1,
    1
  )
  FROM generate_series(1,1e2) AS gs(x)
);

而且,我们打电话给array_to_string() 来获取短信。

SELECT array_to_string(
  ARRAY(
      SELECT substring(
        'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
        trunc(random()*62)::int+1,
        1
      )
      FROM generate_series(1,1e2) AS gs(x)
  )
  , ''
);

从这里我们甚至可以把它变成一个函数..

CREATE FUNCTION random_string(randomLength int)
RETURNS text AS $$
SELECT array_to_string(
  ARRAY(
      SELECT substring(
        'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
        trunc(random()*62)::int+1,
        1
      )
      FROM generate_series(1,randomLength) AS gs(x)
  )
  , ''
)
$$ LANGUAGE SQL
RETURNS NULL ON NULL INPUT
VOLATILE LEAKPROOF;

然后

SELECT * FROM random_string(10);

【讨论】:

嘿,埃文,就避免碰撞和避免可预测性而言,使用random() 而不是gen_random_bytes() 之类的东西是否“安全”? (我意识到避免碰撞是长度的一个因素。)查看 PG 文档,它说:The characteristics of the values returned by random() depend on the system implementation. It is not suitable for cryptographic applications; see pgcrypto module for an alternative. 不,以任何方式使用形状或形式绝对不安全,这就是我要结束这个问题的原因。如果您想要安全使用的东西,请使用 UUID。如果你想玩一些可能会严重灼伤你并让你哭泣的东西。一种解决方案,需要您创建自己的功能,该功能在各方面都比执行此操作的库存功能集更糟糕,然后使用它。 =) 说真的,UUID 很棒。它是用 C 语言编写的,运行速度更快,存储效率更高(空间更小),而且随机性更强。这里有64**length 随机位,在UUID 中你有2**128 随机位。您的字符串必须大于 22 个字符或更大才能存储比 UUID 更多的随机性,此时它的效率已经降低了 10 个字节(40%)。 trunc(random()*62+1)::int + 1 永远不会返回 1(即'a')。范围[1-62] 的正确表达式是(random() * 61)::int + 1,它还保存了一个函数调用。如果必须使用 trunc,trunc(random()*62)::int + 1 可以。使用round,数字必须更改:round(random()*61)::int + 1【参考方案4】:

感谢 Evan Carroll 的回答,我查看了 hashids.org。 对于 Postgres,您必须编译 extension 或运行一些 TSQL functions。 但出于我的需要,我根据 hashids 的想法创建了一些更简单的东西(简短、不可猜测、独特、自定义字母、避免使用脏话)。

随机播放字母表:

CREATE OR REPLACE FUNCTION consistent_shuffle(alphabet TEXT, salt TEXT) RETURNS TEXT AS $$
DECLARE
    SALT_LENGTH INT := length(salt);
    integer INT = 0;
    temp TEXT = '';
    j INT = 0;
    v INT := 0;
    p INT := 0;
    i INT := length(alphabet) - 1;
    output TEXT := alphabet;
BEGIN
    IF salt IS NULL OR length(LTRIM(RTRIM(salt))) = 0 THEN
        RETURN alphabet;
    END IF;
    WHILE i > 0 LOOP
        v := v % SALT_LENGTH;
        integer := ASCII(substr(salt, v + 1, 1));
        p := p + integer;
        j := (integer + v + p) % i;

        temp := substr(output, j + 1, 1);
        output := substr(output, 1, j) || substr(output, i + 1, 1) || substr(output, j + 2);
        output := substr(output, 1, i) || temp || substr(output, i + 2);

        i := i - 1;
        v := v + 1;
    END LOOP;
    RETURN output;
END;
$$ LANGUAGE plpgsql VOLATILE;

主要功能:

CREATE OR REPLACE FUNCTION generate_uid(id INT, min_length INT, salt TEXT) RETURNS TEXT AS $$
DECLARE
    clean_alphabet TEXT := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
    curse_chars TEXT := 'csfhuit';
    curse TEXT := curse_chars || UPPER(curse_chars);
    alphabet TEXT := regexp_replace(clean_alphabet, '[' || curse  || ']', '', 'gi');
    shuffle_alphabet TEXT := consistent_shuffle(alphabet, salt);
    char_length INT := length(alphabet);
    output TEXT := '';
BEGIN
    WHILE id != 0 LOOP
        output := output || substr(shuffle_alphabet, (id % char_length) + 1, 1);
        id := trunc(id / char_length);
    END LOOP;
    curse := consistent_shuffle(curse, output || salt);
    output := RPAD(output, min_length, curse);
    RETURN output;
END;
$$ LANGUAGE plpgsql VOLATILE;

如何使用示例:

-- 3: min-length
select generate_uid(123, 3, 'salt'); -- output: "0mH"

-- or as default value in a table
CREATE SEQUENCE IF NOT EXISTS my_id_serial START 1;
CREATE TABLE collections (
    id TEXT PRIMARY KEY DEFAULT generate_uid(CAST (nextval('my_id_serial') AS INTEGER), 3, 'salt')
);
insert into collections DEFAULT VALUES ;

【讨论】:

【参考方案5】:

此查询生成所需的字符串。只需更改 generate_series 的第二个参数以选择随机字符串的长度。

SELECT
     string_agg(c, '')
FROM (
     SELECT
          chr(r + CASE WHEN r > 25 + 9 THEN 97 - 26 - 9 WHEN r > 9 THEN 64 - 9 ELSE 48 END) AS c
     FROM (
           SELECT
                 i,
                 (random() * 60)::int AS r
           FROM
                 generate_series(0, 62) AS i
          ) AS a
      ORDER BY i
     ) AS A;

【讨论】:

【参考方案6】:

所以我有自己的用例来做这样的事情。我并不是针对最重要的问题提出解决方案,但是如果您正在寻找与我类似的东西,请尝试一下。

我的用例是我需要用尽可能少的字符创建一个随机的外部 UUID(作为主键)。值得庆幸的是,该场景并没有要求需要大量的这些(可能只有数千个)。因此,一个简单的解决方案是结合使用 generate_uid() 和检查以确保下一个序列尚未使用。

我是这样组合的:

CREATE OR REPLACE FUNCTION generate_id (
    in length INT
,   in for_table text
,   in for_column text
,   OUT next_id TEXT
) AS
$$
DECLARE
    id_is_used BOOLEAN;
    loop_count INT := 0;
    characters TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    loop_length INT;
BEGIN

LOOP
    next_id := '';
    loop_length := 0;
    WHILE loop_length < length LOOP
    next_id := next_id || substr(characters, get_byte(gen_random_bytes(length), loop_length) % length(characters) + 1, 1);
    loop_length := loop_length + 1;
    END LOOP;

    EXECUTE format('SELECT TRUE FROM %s WHERE %s = %s LIMIT 1', for_table, for_column, quote_literal(next_id)) into id_is_used;

    EXIT WHEN id_is_used IS NULL;

    loop_count := loop_count + 1;

    IF loop_count > 100 THEN
        RAISE EXCEPTION 'Too many loops. Might be reaching the practical limit for the given length.';
    END IF;
END LOOP;


END
$$
LANGUAGE plpgsql
STABLE
;

这是一个示例表用法:

create table some_table (
    id
        TEXT
        DEFAULT generate_id(6, 'some_table', 'id')
        PRIMARY KEY
)
;

并测试一下它是如何破坏的:

DO
$$
DECLARE
    loop_count INT := 0;

BEGIN

-- WHILE LOOP
WHILE loop_count < 1000000
LOOP

    INSERT INTO some_table VALUES (DEFAULT);
    loop_count := loop_count + 1;
END LOOP;

END
$$ LANGUAGE plpgsql
;

【讨论】:

以上是关于如何在 Postgres 9.6+ 中生成长度为 N 的随机、唯一的字母数字 ID?的主要内容,如果未能解决你的问题,请参考以下文章

Postgres 9.6 如何遍历数组并将每个数组值插入表中?

如何使用 Homebrew 将 Postgis 安装到 Postgres@9.6 的 Keg 安装中?

如何在Debian 8/7上安装PostgreSQL 9.6

自定义聚合函数 parallel = safe 在 postgres 13.3 中生成语法

Postgres 9.6 并行 XPath

从表中的开始日期和结束日期在 Postgres 中生成系列