基于Client ID实现Document ID自增并避免竞态条件的方案咨询
问题背景
现有Document模型结构如下:
# Document模型字段 document_id -> int client_id -> int
需求:基于client_id实现document_id的自增(同一客户的文档ID从1开始递增,不同客户独立计数),同时避免并发创建时的竞态条件,已为(document_id, client_id)设置联合唯一约束。预期效果:
| client_id | document_id |
|---|---|
| 1 | 1 |
| 1 | 2 |
| 2 | 1 |
| 1 | 3 |
现有方案的问题
方案1:before_create回调(存在竞态条件)
通过模型回调设置document_id,但并发创建同一客户的文档时,会因多个事务同时读取到相同的最大值,导致重复值触发唯一约束错误。
代码实现:
before_create :set_identifier def set_identifier self.document_id = client.documents.maximum(:document_id).next end
并发场景下触发错误:
ActiveRecord::RecordNotUnique
PG::UniqueViolation: ERROR: duplicate key value violates unique constraint
方案2:PostgreSQL触发器(需手动reload)
使用数据库触发器实现自增,功能正常,但Rails创建对象后,返回的实例中document_id未同步更新,需手动调用reload才能获取值。
数据库函数与触发器
-- 自增逻辑函数 CREATE OR REPLACE FUNCTION set_document_identifier() RETURNS TRIGGER AS $$ DECLARE max_identifier INTEGER; BEGIN SELECT MAX(document_id) INTO max_identifier FROM documents WHERE client_id = NEW.client_id; IF max_identifier IS NULL THEN max_identifier := 1; END IF; NEW.document_id := max_identifier; RETURN NEW; END; $$ LANGUAGE plpgsql; -- 插入前触发器 CREATE TRIGGER before_insert_trigger_documents BEFORE INSERT ON documents FOR EACH ROW EXECUTE FUNCTION set_document_identifier();
Rails代码示例
doc = Document.create!(client_id: 1) doc.document_id # => nil doc.reload.document_id # => 1
解决方案
一、Rails原生优化:加锁避免竞态
修改before_create回调,通过数据库锁确保同一客户的文档创建操作串行执行,从根源避免竞态条件:
before_create :set_identifier def set_identifier # 对当前客户的文档集合加排他锁,阻止并发事务读取/修改 client.documents.lock(true) # 计算自增ID,处理空值情况 self.document_id = (client.documents.maximum(:document_id) || 0) + 1 end
原理:lock(true)会对查询结果加排他锁(SELECT ... FOR UPDATE),确保同一时间只有一个事务能读取并更新该客户的最大document_id,彻底避免并发冲突。
二、触发器方案优化:自动同步document_id
如果倾向于使用数据库触发器,可通过以下两种方式让Rails创建对象后直接获取更新后的document_id:
1. 使用RETURNING子句手动构建实例
利用PostgreSQL的RETURNING特性,在插入时返回修改后的字段,再手动构建已持久化的模型实例:
attributes = { client_id: 1 } # 执行插入并返回document_id和主键id result = Document.connection.insert( Document.unscoped.insert_sql(attributes), returning: [:id, :document_id], name: 'Document Create' ).first # 构建已持久化的Document实例 doc = Document.new(attributes.merge(id: result['id'], document_id: result['document_id'])) doc.instance_variable_set(:@new_record, false) # 标记为非新记录
2. 自动reload简化调用
在模型中添加after_create回调,自动重载document_id字段,无需手动调用reload:
after_create :reload_document_id def reload_document_id reload(only: :document_id) # 仅重载document_id字段,减少查询开销 end
注意:该方案会增加一次数据库查询,适合对性能要求不高的场景。
触发器函数简化优化
原触发器函数可通过COALESCE简化逻辑,代码更简洁:
CREATE OR REPLACE FUNCTION set_document_identifier() RETURNS TRIGGER AS $$ BEGIN NEW.document_id := COALESCE( (SELECT MAX(document_id) FROM documents WHERE client_id = NEW.client_id), 0 ) + 1; RETURN NEW; END; $$ LANGUAGE plpgsql;
内容的提问来源于stack exchange,提问作者Daniel Sagayaraj

