关于 uuid-primary-keys
面向 Rails 开发者的 UUID 主键实施指南,涵盖 PostgreSQL 和 SQLite。内容侧重 UUIDv7、迁移、生成器配置、Fixtures 与常见错误。
主要功能
- 01配置 PostgreSQL 原生 UUID
- 02使用 SQLite 二进制 UUID 与 Base36
- 03UUIDv7 生成建议
- 04生成器与迁移配置
- 05Fixtures 和关联说明
适用场景
- 01为 Rails 应用采用 UUID 主键
- 02一致地添加 UUID 外键
- 03选择 PostgreSQL 或 SQLite 的 UUID 存储方式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill uuid-primary-keys安装后在 Claude Code 中输入/uuid-primary-keys确认已加载。
附带文件reference.md
详细介绍
Rails 的 UUID 主键
在 Rails 中正确实现 UUID 主键——使用 PostgreSQL 原生类型或 SQLite 二进制存储,并支持 UUIDv7 和可选的 base36 编码。
决策:选择 UUIDv7,而非 UUIDv4
始终推荐 UUIDv7。 它按时间排序、可排序,且索引性能优于 UUIDv4。Ruby 3.3+ 自带 SecureRandom.uuid_v7,无需安装 gem。
| 特性 | 自增 | UUIDv4 | UUIDv7 |
|---|---|---|---|
| 按时间排序 | 是 | 否 | 是 |
| 索引性能 | 最佳 | 较差(随机) | 良好(顺序) |
| 冲突风险 | 不可能 | 极低 | 极低 |
| 可从外部猜测 | 是 | 否 | 否 |
| 安全合并 | 否 | 是 | 是 |
适用 UUID 的情况:ID 出现在 URL、API 或多数据库同步中。适用整数的情况:仅供内部使用且不会暴露的表。
两种方案
PostgreSQL:原生 UUID 列类型
uuid列以原生 128 位类型存储(16 字节)- PostgreSQL 13+ 无需扩展即可使用
gen_random_uuid() - 标准的 36 字符连字符格式:
019533c0-cf4c-7c67-bfad-b7bc2832e3c5 - 基本设置无需自定义代码
SQLite:二进制存储 + Base36 编码
- 使用
blob(16)列存储原始二进制数据(16 字节,效率高) - 使用 Base36 编码生成 25 字符、适合 URL 的字符串:
0k5p9qi1v8j3h6m2n4b7 - 需要注册自定义类型并扩展适配器
- 最初由 Basecamp 为 Fizzy 开发
PostgreSQL 设置
步骤 1:配置生成器默认值
这是 Agent 最常犯的错误——忘记这一步。 如果不设置,每次运行 rails generate model 都会创建整数 ID,你只能手动修正每个迁移。
# config/initializers/generators.rb
Rails.application.config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end
这样可以确保所有生成的迁移都使用 id: :uuid,所有生成的外键列都使用 type: :uuid。
步骤 2:启用 pgcrypto(仅适用于 PostgreSQL < 13)
PostgreSQL 13+ 内置了 gen_random_uuid()。对于旧版本:
class EnablePgcrypto < ActiveRecord::Migration[8.0]
def change
enable_extension "pgcrypto"
end
end
步骤 3:设置 UUID 生成默认值
推荐使用 UUIDv7,并在 ApplicationRecord 中设置:
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
# Generate UUIDv7 for all UUID primary keys
before_create :set_uuid_v7_primary_key
private
def set_uuid_v7_primary_key
pk = self.class.primary_key
return unless pk
column = self.class.columns_hash[pk]
return unless column&.type == :uuid
self[pk] ||= SecureRandom.uuid_v7
end
end
为什么不使用 default: "gen_random_uuid()"? 这会在数据库层生成 UUIDv4。UUIDv7 必须在 Ruby 中生成(直到 PostgreSQL 添加原生 v7 支持为止)。在 ApplicationRecord 中设置默认值,就能在所有地方使用 UUIDv7。
如果你可以接受 UUIDv4,可以跳过 ApplicationRecord 回调,改为设置数据库默认值:
create_table :posts, id: :uuid, default: "gen_random_uuid()" do |t|
# ...
end
步骤 4:编写迁移
class CreatePosts < ActiveRecord::Migration[8.0]
def change
create_table :posts, id: :uuid do |t|
t.string :title, null: false
t.text :body
t.timestamps
end
end
end
外键引用 UUID:
class CreateComments < ActiveRecord::Migration[8.0]
def change
create_table :comments, id: :uuid do |t|
t.references :post, type: :uuid, null: false, foreign_key: true, index: true
t.text :body, null: false
t.timestamps
end
end
end
重要: t.references 始终使用 type: :uuid。如果你完成了步骤 1,生成器默认值会处理这一点,但仍需确认。
SQLite 设置(二进制存储 + Base36)
此方案将 UUID 存储为 16 字节二进制数据,并在 Ruby 中将其表示为 25 字符的 base36 字符串。它效率高,且生成的 ID 简洁、适合 URL 使用。
步骤 1:创建 UUID 类型
# lib/rails_ext/active_record_uuid_type.rb
# frozen_string_literal: true
module ActiveRecord
module Type
class Uuid < Binary
BASE36_LENGTH = 25 # 36^25 > 2^128
class << self
def generate
uuid = SecureRandom.uuid_v7
hex = uuid.delete("-")
hex_to_base36(hex)
end
def hex_to_base36(hex)
hex.to_i(16).to_s(36).rjust(BASE36_LENGTH, "0")
end
def base36_to_hex(base36)
base36.to_s.to_i(36).to_s(16).rjust(32, "0")
end
end
def serialize(value)
return unless value
binary = Uuid.base36_to_hex(value).scan(/../).map(&:hex).pack("C*")
super(binary)
end
def deserialize(value)
return unless value
hex = value.to_s.unpack1("H*")
Uuid.hex_to_base36(hex)
end
def cast(value)
value
end
end
end
end
ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :sqlite3)
步骤 2:创建初始化文件
# config/initializers/uuid_primary_keys.rb
# frozen_string_literal: true
require_relative "../../lib/rails_ext/active_record_uuid_type"
# Auto-generate UUID for models with UUID primary keys
module UuidPrimaryKeyDefault
def load_schema!
define_uuid_primary_key_pending_default
super
end
private
def define_uuid_primary_key_pending_default
if uuid_primary_key?
pending_attribute_modifications << PendingUuidDefault.new(primary_key)
end
rescue ActiveRecord::StatementInvalid
# Table doesn't exist yet
end
def uuid_primary_key?
table_name && primary_key &&
schema_cache.columns_hash(table_name)[primary_key]&.type == :uuid
end
PendingUuidDefault = Struct.new(:name) do
def apply_to(attribute_set)
attribute_set[name] = attribute_set[name].with_user_default(-> { ActiveRecord::Type::Uuid.generate })
end
end
end
# SQLite adapter extensions for UUID support
module SqliteUuidAdapter
extend ActiveSupport::Concern
def lookup_cast_type(sql_type)
if sql_type == "blob(16)"
ActiveRecord::Type.lookup(:uuid, adapter: :sqlite3)
else
super
end
end
def fetch_type_metadata(sql_type)
if sql_type == "blob(16)"
ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
sql_type: sql_type, type: :uuid, limit: 16
)
else
super
end
end
class_methods do
def native_database_types
@native_database_types_with_uuid ||= super.merge(uuid: { name: "blob", limit: 16 })
end
end
end
# Schema dumper: output :uuid instead of blob(16)
module SchemaDumperUuidType
def schema_type(column)
column.sql_type == "blob(16)" ? :uuid : super
end
end
# Table definition: support t.uuid columns
module TableDefinitionUuidSupport
def uuid(name, **options)
column(name, :uuid, **options)
end
end
# Apply extensions
ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.singleton_class.prepend(UuidPrimaryKeyDefault)
ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionUuidSupport)
end
ActiveSupport.on_load(:active_record_sqlite3adapter) do
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter)
ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType)
end
# Apply immediately if already loaded
if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
unless ActiveRecord::ConnectionAdapters::SQLite3Adapter.ancestors.include?(SqliteUuidAdapter)
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter)
end
end
if defined?(ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper)
unless ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.ancestors.include?(SchemaDumperUuidType)
ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType)
end
end
步骤 3:在 ApplicationRecord 中添加回退逻辑
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
before_create :ensure_uuid_primary_key
private
def ensure_uuid_primary_key
pk = self.class.primary_key
return unless pk
column = self.class.columns_hash[pk]
return unless column&.type == :uuid
self[pk] ||= ActiveRecord::Type::Uuid.generate
end
end
步骤 4:配置生成器
# config/initializers/generators.rb
Rails.application.config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end
步骤 5:迁移(与 PostgreSQL 相同)
class CreatePosts < ActiveRecord::Migration[8.0]
def change
create_table :posts, id: :uuid do |t|
t.string :title, null: false
t.text :body
t.timestamps
end
end
end
外键直接使用 t.uuid:
class CreateComments < ActiveRecord::Migration[8.0]
def change
create_table :comments, id: :uuid do |t|
t.uuid :post_id, null: false, index: true
t.text :body, null: false
t.timestamps
end
end
end
使用 UUID 的 Fixtures
PostgreSQL(标准 UUID 格式)
# test/fixtures/posts.yml
published_post:
id: <%= SecureRandom.uuid_v7 %>
title: "Published Post"
created_at: <%= 1.week.ago %>
draft_post:
id: <%= SecureRandom.uuid_v7 %>
title: "Draft Post"
设置确定性的 fixture ID(在不同运行中保持稳定):
published_post:
id: <%= Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "published_post") %>
title: "Published Post"
SQLite Base36
# test/fixtures/posts.yml
published_post:
id: <%= ActiveRecord::Type::Uuid.generate %>
title: "Published Post"
Fixtures 中的外键引用
始终通过 fixture 名称引用,而不是使用原始 ID:
# test/fixtures/comments.yml
first_comment:
post: published_post # ← Rails resolves this to the UUID automatically
body: "Great post!"
关联
关联的用法与整数主键完全相同,无需特殊配置:
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :post
end
唯一需要更改的是迁移。 模型、关联、查询——全部相同。
常见错误
1. 忘记设置生成器默认值
如果没有在生成器中设置 primary_key_type: :uuid,每次 scaffold 都会创建整数 ID。你只能手动修正每个迁移。
2. 使用字符串列而不是 UUID 类型
# WRONG — stores UUID as variable-length string (36 bytes + overhead)
t.string :post_id
# RIGHT — PostgreSQL native uuid (16 bytes)
t.references :post, type: :uuid
# RIGHT — SQLite binary (16 bytes)
t.uuid :post_id
3. 在 references 中漏掉 type: :uuid
# WRONG — creates integer foreign key column
t.references :post, foreign_key: true
# RIGHT — creates UUID foreign key column
t.references :post, type: :uuid, foreign_key: true
4. 有 v7 可用时仍使用 UUIDv4
UUIDv4 完全随机 → 索引局部性较差 → 大规模插入时速度较慢。UUIDv7 按时间排序 → 顺序插入 → 性能更好。需要 Ruby 3.3+。
5. 混用整数和 UUID 主键
如果一开始就使用 UUID,就应在所有地方都使用 UUID。混用 ID 类型会导致联接混乱和迁移麻烦。例外情况是无需自身 ID 的联接表(id: false)。
6. 忘记处理现有表
将现有应用的整数主键转换为 UUID 主键时,需要制定数据迁移策略。不要尝试直接修改现有主键——这会很麻烦。更好的做法是新建 UUID 列、迁移数据,然后替换主键。
适合 URL 的 ID
Base36(SQLite 方案)
已内置于上文的 SQLite 设置中。ID 示例:0k5p9qi1v8j3h6m2n4b7
PostgreSQL:使用标准 UUID
现代框架和 API 都能很好地处理标准 UUID。如果想使用更短的 URL,可以添加单独的 slug 或 short_id 列,而不是对主键进行编码。
路由参数
UUID 无需修改即可用于路由:
# config/routes.rb
resources :posts # Works identically
# Controller
Post.find(params[:id]) # Finds by UUID string
检查当前设置
实施前,务必检查现有项目:
# Check if generator defaults are set
grep -r "primary_key_type" config/
# Check existing migrations for UUID usage
grep -r "id: :uuid" db/migrate/
# Check for pgcrypto extension
grep -r "pgcrypto" db/migrate/ db/schema.rb
# Check database adapter
grep "adapter:" config/database.yml
# Check Ruby version (need 3.3+ for uuid_v7)
ruby -v
参考
请参阅此技能目录中的 reference.md,了解:
- 完整的 SQLite 初始化代码
- Base36 编码/解码详情
- 迁移转换策略
- 测试模式和边缘情况
- 性能基准
- 多数据库注意事项