Rails 5中高效生成Basecamp风格hash_id替代自增ID的方案
嘿,关于在Rails 5里生成高效的Basecamp式Hash ID来替代自增ID的问题,我刚好有几个靠谱的方案,能帮你摆脱频繁exists?查询带来的性能困扰:
这个方案的核心思路是:放弃提前查询exists?,转而依赖数据库的唯一索引保证Hash ID的唯一性,仅在极罕见的碰撞发生时重试。这种方式能大幅减少数据库查询次数,性能提升非常明显。
具体步骤:
给Hash ID字段添加唯一索引
先写一个迁移文件,确保hash_id字段在数据库层面是唯一的:class AddUniqueIndexToYourModelsHashId < ActiveRecord::Migration[5.2] def change add_column :your_models, :hash_id, :string add_index :your_models, :hash_id, unique: true end end在模型中生成Hash ID并处理碰撞
在模型的before_create回调里生成Hash ID,当出现唯一约束冲突时(也就是极罕见的碰撞),捕获异常并重试:class YourModel < ApplicationRecord before_create :generate_hash_id private def generate_hash_id self.hash_id = generate_random_hash_id rescue ActiveRecord::RecordNotUnique # 碰撞概率极低,几乎不会走到这里 retry end def generate_random_hash_id # 生成9位Base62格式的ID(数字+大小写字母),和Basecamp风格一致 chars = ('0'..'9').to_a + ('A'..'Z').to_a + ('a'..'z').to_a (1..9).map { chars.sample }.join # 如果你偏好SecureRandom的实现,也可以用下面的写法 # SecureRandom.urlsafe_base64(9).tr('-_', ('a'..'z').to_a.sample(2).join) end end
为什么高效?
9位Base62的Hash ID有62^9 ≈ 1.3×10¹⁶种可能,碰撞概率低到可以忽略不计。绝大多数情况下,生成Hash ID后直接插入即可,不需要任何额外查询。只有在极端巧合下才会重试一次,性能损耗微乎其微。
如果可以接受Hash ID和自增ID存在映射关系,使用hashids gem是更简单的选择——它会把自增ID加密成短字符串,完全不需要查询或重试,因为每个自增ID对应唯一的Hash ID。
具体步骤:
安装gem
在Gemfile里添加:gem 'hashids'然后执行
bundle install。在模型中配置Hash ID
class YourModel < ApplicationRecord # 用一个安全的盐值(可以存在Rails credentials里) HASHID_SALT = Rails.application.credentials.dig(:hashid, :salt) # 指定Hash ID的长度,和Basecamp保持一致设为9 HASHID_LENGTH = 9 # 生成Hash ID def hash_id @hash_id ||= Hashids.new(HASHID_SALT, HASHID_LENGTH).encode(id) end # 通过Hash ID查找记录 def self.find_by_hash_id(hash_id) decoded_id = Hashids.new(HASHID_SALT, HASHID_LENGTH).decode(hash_id).first find(decoded_id) if decoded_id end end
优缺点:
- 优点:实现零成本,不需要数据库额外查询,也不用担心碰撞。
- 缺点:如果有人知道你的盐值,可能可以反向解码出自增ID,从而推测出你的记录数量和增长速度。如果完全不想暴露自增ID的信息,方案1更合适。
如果你的应用处于超高并发场景,连极罕见的重试都想避免,可以提前预生成一批Hash ID存在一个单独的“池”里,创建记录时直接从池里取未使用的ID。
具体步骤:
创建Hash ID池表
写迁移文件:class CreateHashIdPools < ActiveRecord::Migration[5.2] def change create_table :hash_id_pools do |t| t.string :hash_id, null: false, unique: true t.boolean :used, default: false, null: false t.timestamps end add_index :hash_id_pools, [:used, :created_at] end end预生成Hash ID
写一个Rake任务或者后台Job,定期生成一批Hash ID插入到池里:namespace :hash_ids do desc "预生成一批Hash ID到池里" task generate: :environment do batch_size = 1000 chars = ('0'..'9').to_a + ('A'..'Z').to_a + ('a'..'z').to_a hash_ids = batch_size.times.map { (1..9).map { chars.sample }.join } # 批量插入,避免重复 HashIdPool.insert_all(hash_ids.map { |h| { hash_id: h } }, unique_by: :hash_id) end end从池里取ID创建记录
在模型中通过事务加行锁的方式,取一个未使用的ID:class YourModel < ApplicationRecord before_create :assign_hash_id_from_pool private def assign_hash_id_from_pool # 用行锁保证不会被其他请求抢走同一个ID pool_entry = HashIdPool.where(used: false).lock(true).take if pool_entry self.hash_id = pool_entry.hash_id pool_entry.update!(used: true) else # 如果池空了,临时生成一个(或者触发预生成任务) self.hash_id = generate_random_hash_id end end def generate_random_hash_id chars = ('0'..'9').to_a + ('A'..'Z').to_a + ('a'..'z').to_a (1..9).map { chars.sample }.join end end
优缺点:
- 优点:彻底避免了创建时的查询和重试,性能最优,适合超高并发场景。
- 缺点:需要额外维护ID池,增加了系统复杂度,还要监控池的剩余数量,避免耗尽。
总结一下,如果你想要完全独立于自增ID的随机Hash ID,方案1是最优选择——实现简单,性能损耗几乎可以忽略,碰撞概率极低。如果可以接受Hash ID和自增ID存在映射关系,方案2的hashids gem能让你零查询实现需求。至于方案3,更适合超高并发的场景,需要额外维护ID池,但能彻底避免创建时的性能开销。
内容的提问来源于stack exchange,提问作者chrickso

