侧边栏壁纸
博主头像
colo

欲买桂花同载酒

  • 累计撰写 1823 篇文章
  • 累计收到 0 条评论

高并发票务系统中防止超卖的设计与实现

2025-12-11 / 0 评论 / 4 阅读

题目

高并发票务系统中防止超卖的设计与实现

信息

  • 类型:问答
  • 难度:⭐⭐⭐

考点

并发控制, 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)和重试率