红包系统如何解决并发抢锁的问题(代码+压测分析)

48 阅读5分钟

背景

受到微信红包的设计思路的启发,单独来分享一下这个并发抢锁的问题及解决方案

抢微信红包的事务过程

  1. begin 事务
  2. select ... for update 锁住主红包记录,其他并发请求等待锁释放
  3. 创建子红包记录
  4. 更新库存
  5. commit 事务

主红包加悲观锁的问题

  1. 性能低下,无法支撑高QPS的抢红包请求。
  2. DB压力大,系统整体性能上不去

在这个背景下,微信红包团队使用了串行排队同一个主红包ID的抢请求,FIFO的去处理每个抢红包请求,本质是一种避免抢锁的无锁化设计,其理论基础是: 如果并发抢锁给到DB的压力大,那就避免抢锁,而FIFO的队列是一种比较好的规避手段。 下面用golang来实现这样的一个思想:

  1. 本代码只模拟抢同一个主红包的。
  2. 本地队列: 使用slice + 锁 来实现
  3. http 服务器: 负责创建任务,写任务到本地队列 Enqueue。
  4. worker协程: 从本地队列Dequeu,处理完成后写任务中的chan(size是1的chan)

代码

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"
)

// 1. 定义任务结构:包含业务参数、结果回传通道、任务ID(用于追踪)
type Task struct {
    TaskID        string          // 任务唯一标识
    BusinessParam string          // 业务参数(如红包ID、金额等)
    ResultChan    chan TaskResult // 结果回传通道(每个任务独立通道,避免混淆)
    Timeout       time.Duration   // 任务处理超时时间
}

// 2. 定义任务结果结构
type TaskResult struct {
    TaskID  string // 关联的任务ID
    Success bool   // 处理是否成功
    Data    string // 处理结果数据
    ErrMsg  string // 错误信息(失败时非空)
}

// 3. 并发安全的FIFO队列:基于切片+互斥锁实现
type SafeFifoQueue struct {
    mu    sync.Mutex // 互斥锁,保证并发安全
    items []*Task    // 存储任务的切片
}

// 初始化队列
func NewSafeFifoQueue() *SafeFifoQueue {
    return &SafeFifoQueue{
       items: make([]*Task, 0),
    }
}

// 入队:添加任务到队尾(并发安全)
func (q *SafeFifoQueue) Enqueue(task *Task) {
    q.mu.Lock()
    defer q.mu.Unlock()
    q.items = append(q.items, task)
}

// 出队:从队头取出任务(并发安全)
func (q *SafeFifoQueue) Dequeue() *Task {
    q.mu.Lock()
    defer q.mu.Unlock()
    if len(q.items) == 0 {
       return nil
    }
    // 取出队头任务
    task := q.items[0]
    // 移除队头(切片切片操作)
    q.items = q.items[1:]
    return task
}

// 4. 任务处理器:单协程串行处理队列中的任务
func startTaskProcessor(queue *SafeFifoQueue) {
    log.Println("任务处理器启动,开始串行处理任务...")
    for {
       // 从队列取任务(无任务时会取到nil,短暂休眠后重试)
       task := queue.Dequeue()
       if task == nil {
          time.Sleep(10 * time.Millisecond) // 避免空轮询消耗CPU
          continue
       }

       log.Printf("开始处理任务[%s],参数:%s\n", task.TaskID, task.BusinessParam)

       // 模拟业务处理(如扣减主红包库存、写入子红包记录)
       var result TaskResult
       result.TaskID = task.TaskID

       // 模拟处理耗时(100~300ms)
       processTime := time.Duration(100+int(time.Now().UnixNano()%200)) * time.Millisecond
       time.Sleep(processTime)

       // 模拟业务逻辑(成功/失败)
       if task.BusinessParam == "error" {
          result.Success = false
          result.ErrMsg = "业务处理失败:参数错误"
       } else {
          result.Success = true
          result.Data = fmt.Sprintf("处理完成(耗时:%v),参数:%s", processTime, task.BusinessParam)
       }

       // 将结果回传到任务的通道(带超时保护,避免请求协程已退出导致阻塞)
       select {
       case task.ResultChan <- result:
          log.Printf("任务[%s]处理完成,已回传结果\n", task.TaskID)
       case <-time.After(task.Timeout):
          log.Printf("任务[%s]结果回传超时(请求可能已超时退出)\n", task.TaskID)
       }
       close(task.ResultChan) // 关闭通道,释放资源
    }
}

// 5. HTTP请求处理函数:接收请求→创建任务→入队→等待结果→回包
func handleRequest(w http.ResponseWriter, r *http.Request, queue *SafeFifoQueue) {
    // 解析请求参数
    r.ParseForm()
    param := r.Form.Get("param")
    if param == "" {
       http.Error(w, "参数param不能为空", http.StatusBadRequest)
       return
    }

    // 创建任务:生成唯一ID,初始化结果通道
    task := &Task{
       TaskID:        fmt.Sprintf("task-%d", time.Now().UnixNano()), // 用时间戳生成唯一ID
       BusinessParam: param,
       ResultChan:    make(chan TaskResult, 1), // 带缓冲通道,避免写入阻塞
       Timeout:       5 * time.Second,          // 任务结果回传超时时间
    }

    // 任务入队
    queue.Enqueue(task)
    log.Printf("请求参数[%s]已入队,任务ID:%s\n", param, task.TaskID)

    // 等待任务处理结果(带超时)
    var result TaskResult
    select {
    case result = <-task.ResultChan:
       // 正常获取结果
    case <-time.After(task.Timeout + 100*time.Millisecond):
       // 超时处理(比任务回传超时多留100ms缓冲)
       result = TaskResult{
          TaskID:  task.TaskID,
          Success: false,
          ErrMsg:  "请求超时(超过" + task.Timeout.String() + ")",
       }
    }

    // 回包(JSON格式)
    w.Header().Set("Content-Type", "application/json;charset=utf-8")
    _ = json.NewEncoder(w).Encode(result)
}

func main() {
    // 初始化并发安全队列
    queue := NewSafeFifoQueue()

    // 启动任务处理器(单协程,保证FIFO串行处理)
    go startTaskProcessor(queue)

    // 注册HTTP请求处理函数
    http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
       handleRequest(w, r, queue)
    })

    // 启动HTTP服务
    log.Println("服务启动,监听端口8080,示例请求:http://localhost:8080/process?param=test123")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

压测

压测脚本

curl http://localhost:8080/process?param=test001 &
curl http://localhost:8080/process?param=test002 &
curl http://localhost:8080/process?param=test003 &
curl http://localhost:8080/process?param=test004 &
curl http://localhost:8080/process?param=test005 &
curl http://localhost:8080/process?param=test006 &
curl http://localhost:8080/process?param=test007 &
curl http://localhost:8080/process?param=test008 &
curl http://localhost:8080/process?param=test009 &
curl http://localhost:8080/process?param=test010 &
curl http://localhost:8080/process?param=test011 &
curl http://localhost:8080/process?param=test012 &
curl http://localhost:8080/process?param=test013 &
curl http://localhost:8080/process?param=test014 &
curl http://localhost:8080/process?param=test015 &
curl http://localhost:8080/process?param=test016 &
curl http://localhost:8080/process?param=test017 &
curl http://localhost:8080/process?param=test018 &
curl http://localhost:8080/process?param=test019 &
curl http://localhost:8080/process?param=test020 &

运行截图

客户端回包

image.png

服务端日志

image.png

为啥主红包不用乐观锁?

  1. 用户体验差: 并发请求,只能有1个成功,其他用户抢不到红包
  2. 还是用户体验: 不符合FIFO,先抢的人因为并发失败抢不到,反而后来的人抢到了,体验差。
  3. 事务回滚: 并发失败的请求会触发DB事务回滚,DB压力大。