并发场景下的数据存储:避免重复ID与单据编号问题
嘿,这个并发场景下的竞态条件问题太常见了!我给你整理几个最优解决方案,从最省心的数据库原生方案到适配复杂场景的处理方式,你可以根据自己的技术栈和业务需求挑:
1. 数据库原生自增主键/序列(最推荐,省心又可靠)
别自己手动计算last value + 1了,把这个活儿交给数据库!几乎所有主流数据库都支持原子性的自增ID生成,从根源上避免并发冲突。
针对ID字段
直接用数据库的自增主键特性:
- MySQL 用
AUTO_INCREMENT - PostgreSQL 用
SERIAL或SEQUENCE - SQL Server 用
IDENTITY
示例(MySQL):
CREATE TABLE INVOICE ( ID INT AUTO_INCREMENT PRIMARY KEY, -- 其他字段:比如客户ID、开票日期等 ... ); CREATE TABLE INVOICE_PRODUCTS ( ID INT AUTO_INCREMENT PRIMARY KEY, INVOICE_ID INT NOT NULL, -- 其他商品字段:名称、单价、数量等 ... FOREIGN KEY (INVOICE_ID) REFERENCES INVOICE(ID) );
针对DOCUMENT_NUMBER字段
如果单据号是单纯的自增数字,直接给它加UNIQUE约束的自增字段就行;如果需要带前缀(比如INV-2024-0001),可以结合序列+触发器实现:
示例(PostgreSQL):
-- 创建一个专门生成单据号的序列 CREATE SEQUENCE invoice_doc_seq START 1; CREATE TABLE INVOICE ( ID SERIAL PRIMARY KEY, DOCUMENT_NUMBER VARCHAR(20) UNIQUE NOT NULL, -- 其他字段 ... ); -- 触发器函数:插入前自动生成带前缀的单据号 CREATE OR REPLACE FUNCTION generate_invoice_doc_number() RETURNS TRIGGER AS $$ BEGIN NEW.DOCUMENT_NUMBER = 'INV-' || TO_CHAR(CURRENT_DATE, 'YYYY') || '-' || LPAD(nextval('invoice_doc_seq')::TEXT, 4, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; -- 绑定触发器到INVOICE表 CREATE TRIGGER trigger_gen_invoice_doc BEFORE INSERT ON INVOICE FOR EACH ROW EXECUTE FUNCTION generate_invoice_doc_number();
数据库的自增/序列是原子性操作,同一时间只会给一个请求分配唯一值,完全不用担心重复问题,而且性能开销极低,是首选方案。
2. 乐观锁+重试机制(适合复杂单据号规则)
如果你的单据号有特殊规则(比如和客户ID关联、包含特定编码),没法直接用数据库自增,可以试试乐观锁思路:
- 查询当前最大单据号时,同时获取一个“版本标识”(比如表的最后更新时间、或者专门的version字段)
- 计算出新的单据号后,执行插入/更新操作时,带上这个版本标识做校验
- 如果操作失败(返回影响行数为0),说明有其他用户抢先修改了,重试几次
伪代码示例(Python + SQL):
import psycopg2 def create_invoice(): conn = psycopg2.connect("dbname=your_db user=your_user") cursor = conn.cursor() retry_count = 3 while retry_count > 0: # 1. 查询当前最大单据号和版本标识(这里用最新发票的ID当版本) cursor.execute("SELECT MAX(DOCUMENT_NUMBER), MAX(ID) FROM INVOICE") max_doc_num, latest_id = cursor.fetchone() # 2. 计算新单据号(假设规则是数字+1) new_doc_num = int(max_doc_num) + 1 if max_doc_num else 1 # 3. 插入时校验版本:只有最新ID还是刚才查询的那个,才允许插入 cursor.execute(""" INSERT INTO INVOICE (DOCUMENT_NUMBER, ...) VALUES (%s, ...) WHERE (SELECT MAX(ID) FROM INVOICE) = %s """, (new_doc_num, latest_id)) if cursor.rowcount > 0: conn.commit() print("发票创建成功!") break else: retry_count -= 1 print(f"并发冲突,重试第{3 - retry_count}次...") if retry_count == 0: print("多次重试失败,请稍后再试") conn.close()
这个方案不需要加锁,对并发性能影响小,但需要业务代码处理重试逻辑,适合单据号规则复杂的场景。
3. 分布式ID生成器(分布式系统场景)
如果你的系统是多数据库节点的分布式架构,数据库自增就没法跨节点保证唯一性了,这时候可以用:
- Redis的INCR命令:Redis的自增操作是原子性的,专门用来生成全局唯一的序列值
- Snowflake算法:生成包含时间戳、机器ID、序列号的64位唯一ID,适合高并发分布式场景
示例(Redis生成单据号):
import redis r = redis.Redis(host='your_redis_host', port=6379, db=0) # 原子性生成自增单据号 new_doc_num = r.incr('invoice_doc_counter') # 如果需要前缀,拼接一下 formatted_doc_num = f"INV-{new_doc_num:06d}" # 然后把这个号插入数据库
这个方案适合分布式系统,但需要引入额外组件(Redis或者自定义Snowflake服务),增加了系统复杂度。
4. 事务+排他锁(并发量低的场景)
如果你的系统并发量不高,可以用数据库事务加排他锁的方式,强制同一时间只有一个用户能获取并更新最大单据号:
BEGIN TRANSACTION; -- 加排他锁,阻塞其他查询这个表的请求 SELECT MAX(DOCUMENT_NUMBER) FROM INVOICE FOR UPDATE; -- 计算新单据号 SET @new_doc_num = (SELECT MAX(DOCUMENT_NUMBER) FROM INVOICE) + 1; -- 插入新发票 INSERT INTO INVOICE (DOCUMENT_NUMBER, ...) VALUES (@new_doc_num, ...); COMMIT;
这个方案简单直接,但会阻塞其他并发请求,降低系统吞吐量,只适合并发量低的小型系统。
总结
优先选数据库原生自增/序列方案,它最可靠、性能最好、代码复杂度最低;如果单据号规则复杂,再考虑乐观锁+重试;分布式场景用Redis/Snowflake;并发量极低的情况可以用事务+排他锁。
内容的提问来源于stack exchange,提问作者Ricardo Sousa

