为什么我们为PlanetScale的API选择了NanoID
在我们初次构建PlanetScale的API时,我们需要决定使用哪种标识符类型。我们明确知道,不希望使用整数ID,以免泄露我们所有数据表中的记录数量。
解决此问题的常见方法是使用UUID(通用唯一标识符)。UUID很不错,因为它几乎不可能生成重复,它还能隐藏内部ID。然而,UUID有一个问题,它在URL中占用了大量空间,例如:api.planetscale.com/v1/deploy-requests/7cb776c5-8c12-4b1a-84aa-9941b815d873
试着双击这个URL中的ID来选择并复制它,你会发现不能直接选中。浏览器会将其解释为由5个不同的单词组成。
虽然这看起来只是个小问题,但如果我们希望构建一个让开发者喜欢的产品,那么细节必须考虑周全。
NanoID
我们决定自己的ID需要满足以下几个条件:
- 比UUID更短
- 容易通过双击选中
- 低概率碰撞
- 能够在多种编程语言中方便生成(我们的后端使用Ruby和Go)
这促使我们选择了NanoID,它完全满足这些需求。
以下是一些NanoID示例:
izkpm55j334u z2n60bhrj7e8 qoucu12dag1x
这些ID在URL中更加便于使用:api.planetscale.com/v1/deploy-requests/izkpm55j334u
ID长度与碰撞概率
ID碰撞指的是生成两个相同ID的情况。如果碰撞概率很低,这不是大问题。应用可以检测到碰撞,自动生成新ID,然后继续工作。但如果碰撞经常发生,就会成为一个重大问题。
ID越长、越复杂,发生碰撞的概率越低。所需ID复杂度取决于应用场景。在我们的案例中,我们使用了NanoID碰撞工具,并决定使用12字符长的ID,字母表为0123456789abcdefghijklmnopqrstuvwxyz
。
根据计算,这样的组合会在接下来的约35年中产生1%的碰撞概率,假设我们每小时生成1,000个ID。
如果将来需要降低碰撞概率,我们只需增加ID生成器生成的长度,然后更新数据库模式以支持新的ID大小。
在Rails中生成NanoID
我们的API是一个基于Ruby on Rails的应用。对于所有面向公开的模型,我们在数据库中添加了一个public_id
列。我们仍然为主键使用标准的自增BigInt,而public_id
仅作为外部标识符。
示例数据库模式
我们添加了public_id
列,并同时增加了唯一约束以防止重复:
SQL1CREATE TABLE `user` (
2 `id` bigint NOT NULL AUTO_INCREMENT,
3 `public_id` varchar(12) DEFAULT NULL,
4 `name` varchar(255) NOT NULL,
5 `created_at` datetime(6) NOT NULL,
6 `updated_at` datetime(6) NOT NULL,
7 PRIMARY KEY (`id`),
8 UNIQUE KEY `idx_public_id` (`public_id`)
9) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
自动生成ID
我们创建了一个可以被模型共享的模块,用来自动为我们生成ID。在Rails中,模块(Concern)可以提高代码复用,减少重复。每当应用创建新记录时,这段代码都会运行,并生成ID:
模型代码:
Ruby1# app/models/user.rb
2class User < ApplicationRecord
3 # 对每个需要public_id的模型,包含生成器
4 include PublicIdGenerator
5end
生成器代码:
Ruby1# app/models/concerns/public_id_generator.rb
2
3require "nanoid"
4
5module PublicIdGenerator
6 extend ActiveSupport::Concern
7
8 included do
9 before_create :set_public_id
10 end
11
12 PUBLIC_ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
13 PUBLIC_ID_LENGTH = 12
14 MAX_RETRY = 1000
15
16 PUBLIC_ID_REGEX = /[#{PUBLIC_ID_ALPHABET}]{#{PUBLIC_ID_LENGTH}}\z/
17
18 class_methods do
19 def generate_nanoid(alphabet: PUBLIC_ID_ALPHABET, size: PUBLIC_ID_LENGTH)
20 Nanoid.generate(size: size, alphabet: alphabet)
21 end
22 end
23
24 # 为我们生成随机字符串作为public ID
25 def set_public_id
26 return if public_id.present?
27 MAX_RETRY.times do
28 self.public_id = generate_public_id
29 return unless self.class.where(public_id: public_id).exists?
30 end
31 raise "Failed to generate a unique public id after #{MAX_RETRY} attempts"
32 end
33
34 def generate_public_id
35 self.class.generate_nanoid(alphabet: PUBLIC_ID_ALPHABET)
36 end
37end
在Go中生成NanoID
NanoID生成器可用于多种语言。在PlanetScale,我们还有一个基于Go的后端服务,它也需要生成public_id
。
以下是我们的Go实现代码:
Go1// Package publicid provides public ID values in the same format as
2// PlanetScale’s Rails application.
3package publicid
4
5import (
6 "strings"
7
8 nanoid "github.com/matoous/go-nanoid/v2"
9 "github.com/pkg/errors"
10)
11
12// Fixed nanoid parameters used in the Rails application.
13const (
14 alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
15 length = 12
16)
17
18// New generates a unique public ID.
19func New() (string, error) { return nanoid.Generate(alphabet, length) }
20
21// Must is the same as New, but panics on error.
22func Must() string { return nanoid.MustGenerate(alphabet, length) }
23
24// Validate checks if a given field name’s public ID value is valid according to
25// the constraints defined by package publicid.
26func Validate(fieldName, id string) error {
27 if id == "" {
28 return errors.Errorf("%s cannot be blank", fieldName)
29 }
30
31 if len(id) != length {
32 return errors.Errorf("%s should be %d characters long", fieldName, length)
33 }
34
35 if strings.Trim(id, alphabet) != "" {
36 return errors.Errorf("%s has invalid characters", fieldName)
37 }
38
39 return nil
40}
总结
为开发者提供绝佳的体验是PlanetScale的一项重要目标。这些看似微不足道的细节,例如是否能快速复制ID,都会对体验产生重要影响。NanoID能够满足我们对应用的需求,同时不牺牲开发者体验。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接
本文链接:https://www.choupangxia.com/2025/05/23/why-we-chose-nanoids-for-planetscales-api/