如何在Rails中存储序列数据并避免竞态条件
好问题!我之前也遇到过类似的场景——既要保证参考编号的唯一性,又不想用数据库锁拖慢性能,也不愿在after_create里多跑一次查询。下面几个方案应该能满足你的需求:
1. 数据库原生序列+格式化生成编号
大多数数据库都支持原子性的序列生成器,比如PostgreSQL的SERIAL/BIGSERIAL、MySQL的AUTO_INCREMENT,或者手动创建的序列。你可以直接在插入语句里利用序列的下一个值来生成格式化的参考编号,全程只需要一次插入操作,完全避免竞态。
举个PostgreSQL的例子,假设你要生成REF-2024-000123这种格式的编号:
-- 先创建一个序列(如果用SERIAL的话会自动创建,这里手动演示) CREATE SEQUENCE order_ref_seq START 1; -- 插入时直接生成编号 INSERT INTO orders (reference_number, customer_id, amount) VALUES ( CONCAT('REF-', EXTRACT(YEAR FROM CURRENT_DATE), '-', LPAD(NEXTVAL('order_ref_seq')::TEXT, 6, '0')), 123, 99.99 );
NEXTVAL是数据库原子操作,多个并发请求会自动获取不同的序列值,完全不用担心重复,而且整个过程只有一次INSERT查询,没有额外开销。
2. 应用层生成雪花ID/UUID
如果不需要编号是连续递增的,雪花算法(Snowflake)或者UUID是非常好的选择:
- UUID:数据库原生支持生成(比如PostgreSQL的
uuid_generate_v4()、MySQL的UUID()),可以直接在INSERT语句里生成,或者在应用层生成后插入。UUID本身全球唯一,完全不存在竞态问题。 - 雪花ID:生成的是64位有序整数,包含时间戳、机器ID和序列号,既保证唯一性,又能保持一定的递增性。可以在应用层用现成的库生成,然后直接插入数据库,不需要和数据库交互生成编号,性能拉满。
比如在Ruby on Rails里用雪花ID的示例:
# 先引入snowflake相关的gem(比如snowflake-id) ref_number = SnowflakeId.generate.to_s Order.create!(reference_number: ref_number, customer_id: 123, amount: 99.99)
这种方案完全不需要读取已有编号,从根源上避免了竞态。
3. 乐观锁+重试机制
如果你必须用递增的数字编号,又不想用悲观锁,可以试试乐观锁的思路:先获取当前最大编号,生成新编号,然后插入时验证当前最大编号是否还是之前获取的那个——如果是,就插入成功;如果不是,说明有并发请求先一步插入了,那就重试几次。
举个MySQL的例子:
-- 步骤1:获取当前最大编号 SELECT MAX(reference_number) INTO @max_ref FROM orders; -- 步骤2:生成新编号(假设是纯数字编号) SET @new_ref = @max_ref + 1; -- 步骤3:插入时验证最大编号未变,避免竞态 INSERT INTO orders (reference_number, customer_id, amount) SELECT @new_ref, 123, 99.99 WHERE (SELECT MAX(reference_number) FROM orders) = @max_ref;
如果INSERT返回的受影响行数是0,说明并发冲突了,应用层可以捕获这个情况,自动重试2-3次。这种方案没有锁表操作,性能比悲观锁好很多,而且不需要额外的回调查询。
4. 唯一键冲突重试(适合非严格递增场景)
如果你的编号不需要严格递增,可以把reference_number设为唯一键,然后尝试插入生成的候选编号——如果因为唯一键冲突失败,就重新生成一个新的,直到插入成功。
比如MySQL的例子:
-- 先给reference_number加唯一约束 ALTER TABLE orders ADD UNIQUE KEY idx_unique_ref (reference_number); -- 插入时自动处理冲突(如果冲突就重新生成编号) INSERT INTO orders (reference_number, customer_id, amount) VALUES (CONCAT('REF-', FLOOR(RAND() * 1000000)), 123, 99.99) ON DUPLICATE KEY UPDATE reference_number = CONCAT('REF-', FLOOR(RAND() * 1000000));
这种方案适合对编号顺序没要求的场景,实现简单,也不需要锁或额外查询。
内容的提问来源于stack exchange,提问作者Sachin Mittal

