nanoid 实测:字符集含短横下划线、PouchDB 得手动加前缀、熵只比 UUID 多 4 bits
测试环境
- 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。文中数字来自这台机器上的一次完整运行。
- 点赞
- 收藏
- 关注作者
评论(0)