题目
高并发票务系统中防止超卖的设计与实现
信息
- 类型:问答
- 难度:⭐⭐⭐
考点
并发控制, ActiveRecord事务, 数据库锁机制, 系统性能优化
快速回答
在高并发票务系统中防止超卖的核心方案:
- 使用数据库事务确保库存操作的原子性
- 采用悲观锁(行级锁)或乐观锁(版本控制)处理并发冲突
- 结合唯一索引作为最后防线防止重复创建
- 通过队列系统(如Sidekiq)削峰填谷
- 使用缓存机制减轻数据库压力
问题背景与原理
在票务系统中,当多个用户同时购买最后一张票时,会出现超卖问题。根本原因是:
- 多个进程同时读取相同库存值(如库存=1)
- 所有进程都判断库存充足
- 每个进程都执行库存减1操作
最终导致库存变为负数。解决方案需保证库存检查与扣减的原子性。
解决方案与代码示例
方案1:悲观锁(行级锁)
# 使用with_lock锁定记录
Ticket.transaction do
ticket = Ticket.lock.find(params[:id]) # 添加行级锁
if ticket.quantity > 0
ticket.quantity -= 1
ticket.save!
Order.create!(ticket: ticket, user: current_user)
else
raise '票已售罄'
end
end原理:通过SELECT ... FOR UPDATE锁定记录,其他事务会被阻塞直到锁释放。
方案2:乐观锁(版本控制)
begin
ticket = Ticket.find(params[:id])
ticket.with_lock do # ActiveRecord内置乐观锁
if ticket.quantity > 0
ticket.quantity -= 1
ticket.save! # 自动检查lock_version
Order.create!(ticket: ticket, user: current_user)
end
end
rescue ActiveRecord::StaleObjectError
retry # 重试机制
end原理:通过lock_version字段检测数据冲突,冲突时抛出StaleObjectError。
方案3:数据库原子操作
# 单条SQL完成扣减,避免竞争
updated = Ticket.where(id: params[:id], quantity: ('quantity > 0'))
.update_all('quantity = quantity - 1')
if updated > 0
Order.create!(ticket_id: params[:id], user: current_user)
else
render_sold_out_error
end最佳实践
- 分层防御:
- 前端:按钮防重复点击
- 应用层:队列限流(Sidekiq+Redis)
- 数据库:事务+锁机制
- 性能优化:
- 使用Redis缓存库存信息,减少DB查询
- 读写分离:扣减操作走主库,查询走从库
- 兜底方案:
- 订单表添加
(user_id, ticket_id)唯一索引 - 定时任务核对库存与订单总量
- 订单表添加
常见错误
- N+1查询:在事务中循环查询导致锁表时间过长
- 表级锁滥用:误用
lock("FOR UPDATE NOWAIT")导致性能瓶颈 - 重试风暴:乐观锁retry未设上限导致系统雪崩
- 缓存不一致:更新DB后未及时清除Redis缓存
扩展知识
- 隔离级别影响:Rails默认使用
REPEATABLE_READ,但PostgreSQL的READ_COMMITTED+行锁更高效 - 分布式锁:在微服务架构下需用Redis Redlock替代数据库锁
- 库存预占:引入
reserved_quantity字段分离库存与预占数量 - 性能指标:监控锁等待时间(
pg_stat_activity)和重试率