PostgreSQL 高级并发控制:使用 ON CONFLICT DO NOTHING 实现高并发下的奖励计数限制

张开发
2026/4/18 7:04:22 15 分钟阅读

分享文章

PostgreSQL 高级并发控制:使用 ON CONFLICT DO NOTHING 实现高并发下的奖励计数限制
摘要在高并发场景的营销活动系统中“限制用户领取奖励次数”是一个经典难题。传统的SELECT - CHECK - INSERT/UPDATE模式在并发流量下极易导致数据超限Over-selling。本文将深入探讨 PostgreSQL 的ON CONFLICT DO NOTHING语法特性并通过一个完整的 Go GORM 实战案例展示如何利用数据库唯一约束与原子更新构建一个零错误、零超限的乐观锁奖励计数系统。1. 引言为什么我们需要 ON CONFLICT在会员裂变活动中运营方通常会设置规则一个邀请人最多只能因“被邀请人注册”获得 2 次奖励因“被邀请人首单”获得 2 次奖励。在代码层面如果不加锁多个请求同时到来时的典型执行流程是查询当前已发放次数reward_count。判断reward_count max_limit。若满足执行UPDATE reward_count reward_count 1。这种模式的致命缺陷在于第 1 步和第 3 步之间存在时间窗口。如果有 10 个请求同时通过第 2 步的判断最终计数可能会被错误地更新到远超限制的值。PostgreSQL 提供的INSERT ... ON CONFLICT DO NOTHING与后续的条件UPDATE结合能够将“初始化记录”与“原子递增”无缝衔接从数据库层面消除竞态条件。2. PostgreSQL ON CONFLICT DO NOTHING 深度解析ON CONFLICT是 PostgreSQL 9.5 引入的专门用于处理唯一约束冲突的语法是标准 SQLMERGE的一个子集实现。2.1 核心语义INSERTINTOtable_name(col1,col2)VALUES(val1,val2)ONCONFLICT(unique_column)DONOTHING;当插入的行与表中已有数据在unique_column上发生冲突时PostgreSQL 会直接忽略该插入操作并返回INSERT 0 0不会抛出错误导致事务回滚。2.2 在奖励计数场景中的关键价值在我们的业务中该语法解决了记录不存在时的并发初始化问题场景用户第一次触发事件表中尚无该用户的记录。问题10 个并发请求同时发现记录不存在都尝试执行INSERT。结果利用ON CONFLICT DO NOTHING数据库保证有且仅有一条初始记录被成功创建reward_count 0其余 9 个请求静默跳过INSERT步骤直接进入后续的UPDATE竞争阶段。3. 业务场景与数据模型设计3.1 业务需求字段含义限制逻辑customer_id邀请人 ID维度一activity_id活动 ID维度二trigger_event触发事件 (1:注册, 2:首单)维度三reward_count已发放次数必须 ≤ MaxCount核心约束同一邀请人在同一活动的同一事件下奖励计数不得超过配置的最大值例如 2。3.2 数据库表结构我们设计了promotion_activity_reward_count表关键点在于使用了联合唯一索引来锁定竞争维度。CREATETABLEpublic.promotion_activity_reward_count(idbigserialPRIMARYKEY,customer_idint8NOTNULL,activity_idint8NOTNULL,trigger_eventint4NOTNULL,reward_countint8NOTNULLDEFAULT0,created_attimestamp(6)NOTNULLDEFAULTCURRENT_TIMESTAMP,updated_attimestamp(6)NOTNULLDEFAULTCURRENT_TIMESTAMP);-- 关键索引确保 (邀请人, 活动, 事件) 组合唯一CREATEUNIQUEINDEXidx_inviter_activity_eventONpublic.promotion_activity_reward_countUSINGbtree(customer_id,activity_id,trigger_event);3.3 测试验证截图分析INSERT INTO promotion_activity_reward_count ... ON CONFLICT (customer_id, activity_id, trigger_event) DO NOTHING | 信息 | Affected rows: 0 |当表中已存在(39073, 10, 2)记录时再次执行相同组合的插入操作受影响行数为 0 且未报错。这验证了DO NOTHING的幂等性为高并发场景下的“安全初始化”奠定了基础。4. Go 语言实现基于 GORM 的原子操作我们使用 GORM 封装了数据库操作核心逻辑位于TryIncrement方法中。4.1 接口定义typePromotionActivityRewardCountRepositoryinterface{TryIncrement(ctx context.Context,db*gorm.DB,req PromotionActivityRewardCountTryIncrementRequest)(bool,error)}typePromotionActivityRewardCountTryIncrementRequeststruct{CustomerIdint64ActivityIdint64TriggerEventintMaxCountint// 限制次数例如 2}4.2 核心原子操作逻辑TryIncrement方法分为两个严格顺序的原子步骤幂等初始化 (Idempotent Insert)利用ON CONFLICT DO NOTHING确保统计记录存在。条件原子更新 (Conditional Atomic Update)仅当reward_count MaxCount时执行1。func(t*PromotionActivityRewardCountModel)TryIncrement(ctx context.Context,db*gorm.DB,req PromotionActivityRewardCountTryIncrementRequest)(bool,error){ifreq.MaxCount0{returntrue,nil}now:time.Now()// 步骤 1尝试插入一条奖励计数为 0 的初始记录// 如果发生唯一键冲突PostgreSQL 会静默跳过不会报错err:db.Exec( INSERT INTO promotion_activity_reward_count (customer_id, activity_id, trigger_event, reward_count, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?) ON CONFLICT (customer_id, activity_id, trigger_event) DO NOTHING ,req.CustomerId,req.ActivityId,req.TriggerEvent,now,now).Erroriferr!nil{returnfalse,err}// 步骤 2原子递增// 关键点WHERE 条件中包含 reward_count ? 的判断// 数据库行级锁保证并发安全只有符合条件的 UPDATE 才会影响行数result:db.Table(t.TableName()).Where(customer_id ? AND activity_id ? AND trigger_event ? AND reward_count ?,req.CustomerId,req.ActivityId,req.TriggerEvent,req.MaxCount).Updates(map[string]interface{}{reward_count:gorm.Expr(reward_count 1),updated_at:now,})ifresult.Error!nil{returnfalse,result.Error}// 如果 RowsAffected 0说明 reward_count 已经 MaxCountreturnresult.RowsAffected0,nil}5. 并发安全性测试与证明为了验证上述方案的有效性我们编写了高并发的单元测试使用testify和gomonkey。以下是关键测试用例的分析。5.1 测试场景一模拟超出限制的并发竞争配置MaxCount 5启动20个并发协程尝试获取奖励。预期只有5个协程返回成功success true其余15个返回失败success false且最终数据库计数精确为5。测试代码funcTestTryIncrement_InsertOnConflict_Concurrent(t*testing.T){Init()//这个是初始化数据库的逻辑根据实际项目调整db:Db patches:gomonkey.NewPatches()deferpatches.Reset()monthAfter:time.Now().AddDate(0,1,0)patches.ApplyFunc(time.Now,func()time.Time{returnmonthAfter},)ctx:context.Background()const(customerId1000000001activityId1000000002triggerEvent1maxCount5concurrent20)deferfunc(){db.Table(promotion_activity_reward_count).Where(customer_id ? AND activity_id ? AND trigger_event ?,customerId,activityId,triggerEvent).Delete(nil)}()var(wg sync.WaitGroup mu sync.Mutex successCountinterrorCountint)wg.Add(concurrent)fori:0;iconcurrent;i{gofunc(){deferwg.Done()tx:db.Begin()repo:NewPromotionActivityRewardCountRepository()req:PromotionActivityRewardCountTryIncrementRequest{CustomerId:customerId,ActivityId:activityId,TriggerEvent:triggerEvent,MaxCount:maxCount,}success,err:repo.TryIncrement(ctx,tx,req)iferrnil{tx.Commit()}else{tx.Rollback()}mu.Lock()defermu.Unlock()iferr!nil{errorCount}elseifsuccess{successCount}}()}wg.Wait()assert.Equal(t,0,errorCount,不应该有错误)assert.Equal(t,maxCount,successCount,应该只有 %d 次成功,maxCount)varcounts[]PromotionActivityRewardCount err:db.Table(promotion_activity_reward_count).Where(customer_id ? AND activity_id ? AND trigger_event ?,customerId,activityId,triggerEvent).Find(counts).Error assert.NoError(t,err)assert.Len(t,counts,1,应该只有一条记录)assert.Equal(t,int64(maxCount),counts[0].RewardCount,最终奖励计数应该是 %d,maxCount)}测试结果断言assert.Equal(t,0,errorCount,不应该有错误)assert.Equal(t,maxCount,successCount,应该只有 %d 次成功,maxCount)assert.Equal(t,int64(maxCount),counts[0].RewardCount,最终奖励计数应该是 %d,maxCount)✅结论测试通过。数据库行锁配合WHERE reward_count maxCount条件完美拦截了超限的更新请求。5.2 测试场景二不同维度的隔离性验证配置10 个不同的customer_id每个并发执行 1 次。预期10 个客户均成功插入各自的记录互不干扰。✅结论联合唯一索引(customer_id, activity_id, trigger_event)实现了行级粒度的锁隔离不同客户之间的并发操作完全并行无性能瓶颈。5.3 测试场景三达到最大限制后的拒绝操作对同一用户连续调用 5 次限制为 3 次。预期前 3 次返回true后 2 次返回false。✅结论逻辑严谨状态流转符合预期。6. 深入原理解析为什么不会超限—— 排他锁与 MVCC 双重保障在理解上述代码时读者可能会产生一个疑问数据库的两个事务同时更新同一条数据会有排他锁吗如果没有的话岂不是要等到COMMIT时才能知道是否有冲突答案是肯定的PostgreSQL 的UPDATE语句会自动获取行级排他锁无需手动干预。下面详细解析其底层机制。6.1 UPDATE 语句会自动加排他锁是的PostgreSQL 在执行UPDATE时会自动对被修改的行加排他锁Exclusive Lock。让我们用具体的时序来拆解并发过程时序事务 A事务 B说明1开始事务开始事务-2执行UPDATE ... WHERE reward_count 5-事务 A 获取该行的排他锁3-执行UPDATE ... WHERE reward_count 5事务 B 尝试获取同一行的锁被阻塞等待事务 A 释放锁4事务 A 提交-事务 A 释放锁reward_count变为 55-事务 B 继续执行UPDATE事务 B 获得锁后重新检查当前数据行发现reward_count 5已不满足 5条件于是RowsAffected 06-事务 B 提交事务 B 带着 0 行影响的结果提交不产生实际修改6.2 关键点锁 条件判断 双重保障整个防超限逻辑由两个底层机制共同保证机制 1排他锁防止并发修改UPDATE语句在执行时PostgreSQL 会自动给匹配的行加上排他锁。其他事务如果要修改同一行必须等待当前锁持有者提交或回滚。这确保了同一时刻只有一个事务能真正修改这一行。机制 2WHERE 条件基于最新数据再次校验即使一个事务成功获取了锁在真正执行修改前数据库会基于最新的已提交数据重新评估WHERE条件。因为在等待锁的过程中数据可能已经被之前的事务修改过了如上述时序中的步骤 5。所以WHERE reward_count maxCount成为了防止超限的第二道防线。6.3 MVCC 与数据可见性PostgreSQL 使用MVCC多版本并发控制每个事务看到的并不是实时的最新数据而是符合其隔离级别的快照Snapshot。在Read Committed默认隔离级别下行为如下假设初始状态reward_count 4maxCount 5 事务 A 时间线 1. 开始事务看到 reward_count 4 快照 2. 执行 UPDATE获取锁检查 reward_count 5 ✓ 3. 更新 reward_count 5 4. 提交事务 事务 B 时间线 1. 开始事务看到 reward_count 4 自己的快照 2. 执行 UPDATE由于行被锁进入等待队列... 3. 事务 A 提交后锁释放事务 B 被唤醒 4. 关键此时事务 B 会重新读取行的最新版本已提交的 reward_count 5 5. 基于最新版本重新评估 WHERE 条件发现不满足 5 6. RowsAffected 0更新失败6.4 为什么不需要等到 COMMIT 才发现冲突一个常见的误解是认为UPDATE只是记录操作日志直到COMMIT才真正执行。实际上UPDATE语句是立即执行并加锁的错误理解 1. 事务 AUPDATE只记录意图不执行 2. 事务 BUPDATE只记录意图不执行 3. 事务 ACOMMIT才真正执行 UPDATE 4. 事务 BCOMMIT才发现冲突报错 正确理解 1. 事务 AUPDATE立即执行加锁修改数据但其他事务不可见 2. 事务 BUPDATE尝试执行被阻塞因为锁被 A 持有 3. 事务 ACOMMIT释放锁修改对外可见 4. 事务 BUPDATE被唤醒重新检查条件决定是否修改 5. 事务 BCOMMIT提交自己的结果正因如此我们的代码才能在TryIncrement方法中通过判断RowsAffected 0立即得知是否更新成功而无需等到事务提交。7. 总结与思考7.1 方案优势特性传统 SELECT FOR UPDATE本方案 (ON CONFLICT WHERE 条件更新)首次插入并发需额外处理死锁或重复插入错误完美解决DO NOTHING静默处理性能开销锁定范围可能较大容易产生锁等待依赖唯一索引扫描和行级锁开销极小代码复杂度需显式开启事务、处理Rollback仅需两条 SQL 语句逻辑清晰防超限能力依赖FOR UPDATE悲观锁依赖WHERE reward_count limit乐观锁条件结合排他锁双重保障7.2 注意事项必须存在唯一约束ON CONFLICT必须指定一个有效的唯一索引或约束否则会报错。事务上下文INSERT和UPDATE必须在同一个数据库事务中执行以确保原子性本案例中外部传入了*gorm.DB事务对象。避免幽灵更新UPDATE语句的WHERE条件务必包含reward_count MaxCount这是防止超限的最后一道防线。通过 PostgreSQL 的ON CONFLICT DO NOTHING特性与底层的排他锁、MVCC 机制我们得以用极简的代码逻辑构建了一个在高并发流量下坚如磐石的奖励计数系统。这种方法不仅适用于营销活动也可广泛应用于库存扣减、优惠券领取等需要严格计数控制的业务场景。

更多文章