You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于Client ID实现Document ID自增并避免竞态条件的方案咨询

按客户维度自增document_id的Rails实现方案

问题背景

现有Document模型结构如下:

# Document模型字段
document_id -> int
client_id -> int

需求:基于client_id实现document_id的自增(同一客户的文档ID从1开始递增,不同客户独立计数),同时避免并发创建时的竞态条件,已为(document_id, client_id)设置联合唯一约束。预期效果:

client_iddocument_id
11
12
21
13

现有方案的问题

方案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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 20:09:58