nanoid 实测:字符集含短横下划线、PouchDB 得手动加前缀、熵只比 UUID 多 4 bits

举报
yd_232353574 发表于 2026/10/24 07:22:59 2026/10/24
【摘要】 nanoid 零依赖 JS ID 生成库,实测字符集含短横下划线、部分数据库需加前缀。

测试环境

  • nanoid 6.0.2(npm 当前最新版)
  • Node.js v24.13.1
  • Windows 10 x64(19045)
  • 12 条检查写在一个 check.mjs 里,跑一遍几秒钟,原始输出贴在下面

一、这个库做什么

前端要生成唯一 ID 的地方很多:订单号、会话 ID、DOM key、数据库主键。crypto.randomUUID() 给 36 个字符,带 4 个短横和版本位,贴进日志里不好读。

nanoid 的做法是换一套更大的字符集,把长度压下来。默认取 21 个字符,字符集 64 个:A-Z(26)、a-z(26)、0-9(10)、-、_。每生成一个 ID 就从这 64 个字符里随机取 21 次拼起来,长度可以自己指定。作者 Andrey Sitnik,MIT 许可,零依赖。

import { nanoid } from 'nanoid'
nanoid() // => "V1StGXR8_Z5jdHi6B-myT"

官方 README 有四条主张,我逐条对了:

  • 117 bytes(minified + brotli)、无依赖
  • 比原生 crypto.randomUUID() 快 50%
  • 字符集比 UUID 大,长度从 36 压到 21
  • 随机位数和 UUID 接近(126 对 122)

第一条是打包体积,没测。其余三条都跑了数据,其中第二条的实测结果和官方差得比较大。

二、12 条检查

2.1 默认长度

T1 len: 21/21 diff=true

连生两次都是 21 位,内容不同。

2.2 自定义长度

T2: 5,10,16,32,64

传入 5、10、16、32、64,返回长度与参数一致,没有截断也没有补位。

2.3 字符集

T3 alphabet: 57X6y3wQZNzYjJB2L_10pxbackgroundhimselfUTF-8DEMOGRAPHICSVqvt4KW9
T3 len: 64
T3 has dash: true
T3 has underscore: true
T3 dup chars: 64

urlAlphabet 这个常量里能看到 _ 和 - 各一个,去重后正好 64 个,没有重复字符。这两个符号是后面两个问题的根源。

2.4 自定义字符集

T4 hex: 9750c8bf697e5da4 ok=true

customAlphabet('0123456789abcdef', 16) 生成的 16 位字符串全部落在十六进制字符里。需要固定格式 ID 时走这条路,不必事后再过滤。

2.5 100 万个 21 位 ID 的碰撞

T5 uniq: 1000000 dup=0 ms=705.98

100 万个 ID 塞进 Set,长度仍是 100 万,零重复,耗时 706 毫秒,合每秒约 142 万个。这个数包含去重开销,纯粹生成的开销见 2.9。

2.6 不同长度各测 10 万个

T6 len8: uniq=100000 dup=0
T6 len12: uniq=100000 dup=0
T6 len16: uniq=100000 dup=0
T6 len21: uniq=100000 dup=0
T6 len32: uniq=100000 dup=0

8 位到 32 位在 10 万这个量级都没撞。这只说明 10 万次没撞,不代表 8 位能随便用,2.8 算一下就清楚了。

2.7 熵

T7 len8: 48.00 bits, 2.8147e+14 values
T7 len12: 72.00 bits, 4.7224e+21 values
T7 len16: 96.00 bits, 7.9228e+28 values
T7 len21: 126.00 bits, 8.5071e+37 values
T7 len32: 192.00 bits, 6.2771e+57 values

公式是 长度 × log2(64),每字符 6 bits。21 位就是 126 bits。UUID v4 的 128 位里有 6 位固定(4 位版本号、2 位变体),真正随机的只有 122 位,所以 nanoid 多 4 bits。

2.8 理论碰撞率和实测对得上吗

生日问题近似公式 p ≈ n² / (2N),其中 N = 64^长度。12 位、100 万个 ID 代入:

理论碰撞率 = 1 - exp(-n(n-1)/(2N)) = 1.059e-8 %

一千万分之一不到,和 2.6 里 10 万个零碰撞并不矛盾。反过来,8 位(48 bits)在 1000 万个 ID 时理论碰撞率约 16%,在 100 万个时是 0.18%,这个长度就不是「测了没撞所以没问题」能糊过去的。

很多文章爱引用「12 位有 90% 碰撞率」这类数字,通常是把它误写成了碰撞率。严格说那是「生成多少个 ID 才会有一半概率撞车」的口径问题,引用前自己代一遍公式最稳妥。

2.9 生成速度

T9 nanoid ops/s: 16304702

100 万次循环、7 轮交替取中位:1630 万次/秒。数字在不同进程间会浮动,最低跑过 1370 万,最高 1740 万,所以我下面所有性能数字都标了测法。

2.10 换字符集对速度的影响

同一个基准方法(100 万次、3 轮取中位),几种字符集的差别相当大:

T10 默认 64 字符集 21 位: 17597517 ops/s
T10 62 字符集 21 位:      8524379 ops/s
T10 16 字符集(hex) 16 位: 21209582 ops/s
T10 3 字符集(abc) 10 位:  10519816 ops/s

16 个字符的十六进制字符集反而最快,21 位默认字符集排第二。62 个字符(去掉 - 和 _)掉到 852 万,比 64 字符集慢一半。

原因在实现里:customAlphabet 用掩码加拒绝采样取随机数。字符集大小是 2 的幂(16、64)时,掩码刚好覆盖,几乎不浪费随机字节;62 这种非 2 的幂会用 6 位掩码取到 0–63,落在 62、63 上就重抽,约 3% 的丢弃率叠上循环判断,整体就慢下来了。

所以「自定义字符集更慢」这个说法只在字符集大小不是 2 的幂时成立,别一概而论。

2.11 字符集里有没有需要 URL 编码的字符

T11 unsafe found: none

拿 ~ . ! ' ( ) * + = / ? & # % 空格 @ 逐个去 urlAlphabet 里找,一个都没命中。nanoid 生成的 ID 放进 URL 路径、查询参数、# 片段都不需要百分号编码,官方说的 URL-friendly 在这一层站得住。

2.12 10 万个 21 位 ID 的唯一性

T12 uniq: 100000

和 2.5 同一件事的轻量版,10 万个全唯一。

三、和 crypto.randomUUID() 的正面对比

同一进程里交替跑,各 7 轮、每轮 50 万次,取中位:

nanoid:       16304702 ops/s
randomUUID:    4914135 ops/s
比值: 3.32 倍

官方 README 写的是 50% faster,这台机器上测出来是 3 倍多。我最早用「连续跑完 A 再跑 B」的方式测,比值只有 1.4 倍,换成交替跑就稳定在 3.3 倍上下。两种测法的差别在预热和 CPU 频率波动,这正好说明这类比值不能当规格用。

长度上的差别是确定的:

nanoid 长度: 21 , 示例: zjw0ytpo6YJC5iB4IwtwN
randomUUID 长度: 36 , 示例: e55179f6-de3a-460a-98f1-ec2932f92822

省 42% 的字符,日志和 URL 里肉眼可读性差别挺明显。

四、ID 首字符分布

T8 _first: 1561 (1.561%)
T8 -first: 1603 (1.603%)

10 万个 ID 里,以 _ 开头的 1561 个,以 - 开头的 1603 个。理论值 1/64 = 1.5625%,两组都对得上,说明 64 个字符是均匀取用的。

这个分布说明实现没问题,但它意味着每 64 个 ID 里大概有 1 个以 _ 或 - 打头,往下看。

五、CLI

$ npx nanoid --version
6.0.2
$ npx nanoid -s 15
Nvksgx9SNxnFU9t
$ npx nanoid --size 10 --alphabet abc
bacccaaccc
$ npx nanoid -s 8 -a 01
01111101

-s/--size 和 -a/--alphabet 都生效,输出长度和字符范围都对。临时造测试数据够用了。

六、non-secure 版本

nonsecure 0A9Ic6ILq0dbCUQMzVeWB qwGLtG8_IsX-vKZP

从 nanoid/non-secure 导入,格式和默认版一样,长度也对。区别在随机源换成 Math.random(),可预测,不能用于令牌、邀请码这类需要防猜的东西。只在「ID 只要唯一、不怕被猜」时用,比如本地缓存键、测试数据。

七、四个实测出来的坑

1. PouchDB / CouchDB 的文档 ID 不能用 _ 开头

这是最实际的一个。CouchDB 把 _ 开头的文档 ID 留给自己用(_design/xxx、_local/xxx 这类),普通文档用这种 ID 会被拒。nanoid 默认 ID 有 1.5625% 的概率以 _ 开头,10 万个里实测 1561 个。

官方 README 在 PouchDB 一节里给了写法:

model.id = 'id' + nanoid()

加一个固定前缀。不加的话,大约每 64 条写入就有 1 条失败,而且失败是概率性的。本地测几十条可能一次都碰不上,上线后才零星报错,排查成本比加两个字符高得多。

顺带把「URL-friendly」理清:- 和 _ 在 RFC 3986 里属于 unreserved 字符,放路径、查询、片段都不需要编码,2.11 也验证了字符集里没有别的要编码的符号。它的问题不在 URL,而在下游系统对特定前缀的额外规定。

2. 性能数字别照抄 README

同一台机器上,我测到的比值从 1.4 倍到 3.3 倍都有,取决于测法。README 的 50% 可能来自另一套环境(作者在 Linux、不同 Node 版本下测)。批量写入场景还有一个更实际的口径:2.5 里带 Set 去重是每秒 142 万个,2.9 纯生成是每秒 1630 万个,差一个数量级。写技术方案引用吞吐量时,把测法一起写上,别人才能复现。

3. 不要拿 nanoid() 当 React key

官方文档明确提醒过。每次 render 调一次 nanoid(),key 就变了,React 会认为整列元素都是新的,卸载旧的、挂载新的,输入框焦点和滚动位置全丢。key 要稳定,用后端返回的 ID 或数据本身的唯一字段。

4. 126 bits 不等于「更安全」

比 UUID v4 多 4 bits,碰撞概率低 16 倍,但两边都远低于现实系统的碰撞阈值。选 nanoid 的理由是短、快、字符集适合 URL,不是安全性。真要防猜测,21 位 126 bits 的暴力枚举难度和 UUID 是一个档次。

八、放进项目前先定这几条

1. 当数据库主键用,先查前缀规则。 CouchDB/PouchDB 必须加前缀;换到别的存储时,也先确认 key 有没有保留前缀或保留字符,别默认「URL 安全就等于哪都能放」。 2. 长度按碰撞预算定,不要默认 21。 100 万条以内 12 位够用(理论碰撞率 1e-8%),10 亿条级别再用 21 位。长度每减 6 个字符,熵少 36 bits。 3. 需要固定格式就用 customAlphabet,字符数尽量取 2 的幂。 十六进制 16、base64url 64 都比 62 快一倍左右,这条是 2.10 里实打实测出来的。 4. 只看唯一性的场景可以用 non-secure 版,不占加密随机源;令牌、邀请码、重置链接一律用默认版。 5. 压测在自己的运行时里做。 浏览器、Node、Deno 的随机源实现不同,我这组数字只对 Node 24 + Windows 10 有效。

附录:复现方式

把下面这段存成 check.mjs,在装好 nanoid 的目录里 node check.mjs 运行,输出口径与上文一致(性能数字会随机器浮动):

import { nanoid, customAlphabet, urlAlphabet } from 'nanoid'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'

console.log('len', nanoid().length, nanoid(12).length)
console.log('alphabet', urlAlphabet.length,
  urlAlphabet.includes('-'), urlAlphabet.includes('_'))

const hex = customAlphabet('0123456789abcdef', 16)
console.log('hex', /^[0-9a-f]{16}$/.test(hex()))

const set = new Set()
let t = performance.now()
for (let i = 0; i < 1e6; i++) set.add(nanoid())
console.log('1M uniq', set.size, 'ms', (performance.now() - t).toFixed(2))

let u = 0, d = 0
for (let i = 0; i < 1e5; i++) {
  const x = nanoid()
  if (x[0] === '_') u++
  if (x[0] === '-') d++
}
console.log('prefix _', u, 'prefix -', d)

const run = (f) => {
  const start = performance.now()
  for (let i = 0; i < 5e5; i++) f()
  return 5e5 / ((performance.now() - start) / 1000)
}
run(() => nanoid()); run(() => randomUUID())   // 预热
const A = [], B = []
for (let r = 0; r < 7; r++) { A.push(run(() => nanoid())); B.push(run(() => randomUUID())) }
A.sort((x, y) => x - y); B.sort((x, y) => x - y)
console.log('median nanoid', Math.round(A[3]), 'uuid', Math.round(B[3]))

console.log('theory 12char 1M %',
  (1 - Math.exp(-(1e6 * (1e6 - 1)) / (2 * 64 ** 12))) * 100)

环境:nanoid 6.0.2、Node.js v24.13.1、Windows 10 x64。文中数字来自这台机器上的一次完整运行。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。