3秒出单200ms响应:餐饮收银系统性能密码

3秒出单200ms响应:餐饮收银系统性能密码

餐饮收银系统有一个硬性指标:从顾客点完菜到小票打印出来,不能超过3秒;从收银员按下"收款"到系统响应,不能超过200ms

这不是拍脑袋定的。3秒是顾客等待的耐心极限,200ms是收银员操作的手感极限——超过这个时间,收银员会以为系统卡了,反复点击,结果就是重复下单。

这篇我们聊龙讯餐饮V2是怎么做到这两个指标的。

连接池:别让数据库连接成为瓶颈

Go的database/sql自带连接池,但默认配置不适合高并发场景。我们的调优参数:

db, err := sql.Open("postgres", dsn)
if err != nil {
    log.Fatal(err)
}

// 最大打开连接数
// 餐饮高峰期并发量大,设为100以应对高峰
db.SetMaxOpenConns(100)

// 最大空闲连接数
// 设为MaxOpenConns的一半,避免频繁创建和销毁连接
db.SetMaxIdleConns(10)

// 连接最大存活时间
// PostgreSQL默认的连接超时是2小时,我们设为1小时,留余量
db.SetConnMaxLifetime(time.Hour)

// 空闲连接最大存活时间
// 避免长时间空闲的连接被PostgreSQL主动断开
db.SetConnMaxIdleTime(30 * time.Minute)

GORM复用database/sql的连接池,所以这些设置对GORM同样有效:

gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
sqlDB, _ := gormDB.DB()
sqlDB.SetMaxOpenConns(100)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(time.Hour)
sqlDB.SetConnMaxIdleTime(30 * time.Minute)

连接池监控

连接池的指标需要监控,否则出了问题只能靠猜:

// Prometheus指标暴露
func collectDBMetrics(db *sql.DB) {
    go func() {
        ticker := time.NewTicker(10 * time.Second)
        for range ticker.C {
            stats := db.Stats()
            dbOpenConnections.Set(float64(stats.OpenConnections))
            dbInUse.Set(float64(stats.InUse))
            dbIdle.Set(float64(stats.Idle))
            dbWaitCount.Add(float64(stats.WaitCount))
        }
    }()
}

关键指标:

指标 含义 告警阈值
OpenConnections 当前打开的连接数 > MaxOpenConns * 0.8
InUse 正在使用的连接数 > MaxOpenConns * 0.9
WaitCount 等待连接的累计次数 持续增长
WaitDuration 等待连接的累计时间 > 100ms

Redis原子日流水号:并发下单不冲突

餐饮收银系统的订单号有个特殊要求:按天递增,每天从1开始。比如2026年7月18日的第一单是20260718001,第二单是20260718002

在并发场景下,如果用数据库的自增ID,会有锁竞争。如果用SELECT MAX(seq) + 1,会有竞态条件——两个请求同时读到MAX(seq)=100,都生成101。

我们的方案是Redis原子递增

func (s *OrderService) NextDailySeq(ctx context.Context, tenantID int64, storeID int64) (int64, error) {
    today := time.Now().Format("20060102")
    key := fmt.Sprintf("t:%d:s:%d:daily_seq:%s", tenantID, storeID, today)

    // INCR是原子操作,并发安全
    seq, err := s.rdb.Incr(ctx, key).Result()
    if err != nil {
        return 0, err
    }

    // 首次创建时设置过期时间(48小时,留余量)
    if seq == 1 {
        s.rdb.Expire(ctx, key, 48*time.Hour)
    }

    return seq, nil
}

为什么不用数据库序列?因为Redis的INCR是O(1)操作,单线程保证原子性,不需要事务,不需要锁。在10个收银员同时下单的场景下,Redis轻松应对。

生成完整订单号:

func (s *OrderService) GenerateOrderNo(ctx context.Context, tenantID int64, storeID int64) string {
    seq, _ := s.NextDailySeq(ctx, tenantID, storeID)
    today := time.Now().Format("20060102")
    return fmt.Sprintf("%s%03d", today, seq) // 20260718001
}

缓存三大策略

缓存是性能优化的利器,但用不好就是灾难。我们用了三种缓存策略,应对不同场景。

策略一:延迟双删

场景:菜品列表缓存。菜品不常变更,但变更后必须立即生效。

延迟双删的思路是:更新数据库前删一次缓存,更新后再延迟删一次

为什么需要删两次?因为在更新数据库的过程中,可能有请求读到了旧数据并写入了缓存。第二次删除就是为了清除这个"脏缓存"。

func (s *DishService) UpdateDish(ctx context.Context, dish *Dish) error {
    cacheKey := fmt.Sprintf("t:%d:dish:%d", dish.TenantID, dish.ID)

    // 第一步:先删缓存
    s.cache.Del(ctx, cacheKey)

    // 第二步:更新数据库
    if err := s.db.Save(dish).Error; err != nil {
        return err
    }

    // 第三步:延迟再删一次
    go func() {
        time.Sleep(500 * time.Millisecond) // 等待进行中的读请求完成
        s.cache.Del(context.Background(), cacheKey)
    }()

    return nil
}

延迟时间怎么定?一般设为一次读请求的最大耗时。我们的P99读延迟是200ms,所以设了500ms,留了余量。

策略二:布隆过滤器防缓存穿透

场景:查询一个不存在的菜品ID。如果直接查数据库,每次都会穿透到数据库,恶意请求可以打垮数据库。

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,可以判断"某个元素一定不存在"或"可能存在"。

type BloomFilterService struct {
    rdb *redis.Client
}

// 添加元素到布隆过滤器
func (s *BloomFilterService) Add(ctx context.Context, key string, value string) error {
    // 使用Redis的SETBIT实现布隆过滤器
    hash1 := hashFunc1(value)
    hash2 := hashFunc2(value)
    for i := 0; i < 7; i++ { // 7个哈希函数
        bitPos := (hash1 + uint64(i)*hash2) % (1 << 20) // 1MB的位图
        if err := s.rdb.SetBit(ctx, key, int64(bitPos), 1).Err(); err != nil {
            return err
        }
    }
    return nil
}

// 检查元素是否可能存在
func (s *BloomFilterService) MightExist(ctx context.Context, key string, value string) (bool, error) {
    hash1 := hashFunc1(value)
    hash2 := hashFunc2(value)
    for i := 0; i < 7; i++ {
        bitPos := (hash1 + uint64(i)*hash2) % (1 << 20)
        bit, err := s.rdb.GetBit(ctx, key, int64(bitPos)).Result()
        if err != nil {
            return true, err // 出错时放行,避免误杀
        }
        if bit == 0 {
            return false, nil // 一定不存在
        }
    }
    return true, nil // 可能存在
}

使用方式:

func (s *DishService) GetDish(ctx context.Context, id int64) (*Dish, error) {
    // 先查布隆过滤器
    exists, _ := s.bloom.MightExist(ctx, "dish_bloom", strconv.FormatInt(id, 10))
    if !exists {
        return nil, ErrDishNotFound // 一定不存在,直接返回
    }

    // 查缓存
    // ... 缓存逻辑

    // 查数据库
    // ... 数据库逻辑
}

策略三:互斥锁防缓存击穿

场景:热点菜品缓存过期的瞬间,大量请求同时涌入数据库。这就是缓存击穿(Cache Breakdown)。

互斥锁的思路是:只让一个请求去加载数据,其他请求等待

func (s *DishService) GetDishWithLock(ctx context.Context, id int64) (*Dish, error) {
    cacheKey := fmt.Sprintf("t:%d:dish:%d", getTenantID(ctx), id)
    lockKey := fmt.Sprintf("lock:%s", cacheKey)

    // 1. 查缓存
    data, err := s.cache.Get(ctx, cacheKey)
    if err == nil {
        return decodeDish(data), nil
    }

    // 2. 缓存未命中,尝试获取互斥锁
    locked, err := s.rdb.SetNX(ctx, lockKey, 1, 5*time.Second).Result()
    if err != nil {
        return nil, err
    }

    if locked {
        // 获得锁,从数据库加载
        defer s.rdb.Del(ctx, lockKey)

        var dish Dish
        if err := s.db.First(&dish, id).Error; err != nil {
            return nil, err
        }

        // 写入缓存
        s.cache.Set(ctx, cacheKey, encodeDish(&dish), 10*time.Minute)
        return &dish, nil
    }

    // 没获得锁,等待后重试
    time.Sleep(100 * time.Millisecond)
    return s.GetDishWithLock(ctx, id) // 递归重试,最多3次(实际代码会加重试限制)
}

三种策略的适用场景:

策略 解决的问题 适用场景 额外开销
延迟双删 缓存与数据库不一致 数据更新后需立即生效 一次额外的缓存删除
布隆过滤器 缓存穿透 查询不存在的数据 初始化时构建布隆过滤器
互斥锁 缓存击穿 热点数据过期 一次Redis SETNX

高级缓存策略

在实际的CacheService实现中,我们封装了更多高级策略:

1. 布隆过滤器(bloomAdd/bloomCheck)

与上面手写版本不同,实际实现封装在CacheService中,使用Redis的SETBIT:

func (s *CacheService) bloomAdd(ctx context.Context, prefix string, item string) {
    key := s.bloomKey(prefix)
    s.rdb.SetBit(ctx, key, s.bloomHash(item), 1)
}

func (s *CacheService) bloomCheck(ctx context.Context, prefix string, item string) bool {
    key := s.bloomKey(prefix)
    val, err := s.rdb.GetBit(ctx, key, s.bloomHash(item)).Result()
    if err != nil {
        return true // 出错时放行,避免误杀
    }
    return val == 1
}

2. 延迟双删(DeleteWithDoubleDelete)

封装为CacheService的方法,使用time.AfterFunc实现延迟删除:

func (s *CacheService) DeleteWithDoubleDelete(ctx context.Context, key string) {
    s.rdb.Del(ctx, key)
    rdb := s.rdb
    time.AfterFunc(500*time.Millisecond, func() {
        bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()
        rdb.Del(bgCtx, key)
    })
}

3. 互斥锁缓存重建(GetWithMutex)

缓存未命中时,只让一个请求去数据库加载,其他请求等待:

func (s *CacheService) GetWithMutex(ctx context.Context, key string, ttl time.Duration, fetchFunc func() (interface{}, error)) (interface{}, error) {
    val, err := s.rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil // 缓存命中
    }
    if err != redis.Nil {
        return nil, err
    }

    mutexKey := key + ":mutex"
    acquired, _ := s.rdb.SetNX(ctx, mutexKey, "1", 5*time.Second).Result()
    if acquired {
        defer s.rdb.Del(ctx, mutexKey)
        data, fetchErr := fetchFunc()
        if fetchErr != nil {
            return nil, fetchErr
        }
        encoded, _ := json.Marshal(data)
        s.rdb.Set(ctx, key, encoded, ttl)
        return data, nil
    }

    // 未获得锁,等待后重试
    for i := 0; i < 50; i++ {
        time.Sleep(100 * time.Millisecond)
        val, err = s.rdb.Get(ctx, key).Result()
        if err == nil {
            return val, nil
        }
    }
    return fetchFunc() // 超时后直接查库
}

4. 随机TTL抖动(SetWithRandomTTL)

防止大量缓存同时过期导致缓存雪崩,在基础TTL上添加±15秒的随机抖动:

func (s *CacheService) SetWithRandomTTL(ctx context.Context, key string, value interface{}, baseTTL time.Duration) {
    jitter := time.Duration(rand.Int63n(int64(30*time.Second))) - 15*time.Second
    ttl := baseTTL + jitter
    if ttl < time.Minute {
        ttl = baseTTL // 保底:不低于1分钟
    }
    s.rdb.Set(ctx, key, value, ttl)
}

5. NULL哨兵值

防止缓存穿透的另一种手段——查询数据库返回空结果时,缓存一个NULL哨兵值,避免反复穿透:

const nullCacheSentinel = "NULL"

// 查询时:数据库无结果则缓存NULL哨兵
if result == nil {
    s.rdb.Set(ctx, key, nullCacheSentinel, 5*time.Minute)
    return nil, ErrNotFound
}

// 读取时:遇到NULL哨兵直接返回未找到
val, _ := s.rdb.Get(ctx, key).Result()
if val == nullCacheSentinel {
    return nil, ErrNotFound
}

并发订单处理:防止重复下单

餐饮高峰期,收银员可能因为系统响应慢而重复点击"下单"按钮。如果不做处理,就会产生重复订单。

我们的防重复方案是幂等键(Idempotency Key)

func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // 1. 生成幂等键:基于请求内容的哈希
    idempotencyKey := s.generateIdempotencyKey(req)

    // 2. 尝试设置幂等键(SET NX)
    lockKey := fmt.Sprintf("idem:%s", idempotencyKey)
    ok, err := s.rdb.SetNX(ctx, lockKey, 1, 30*time.Second).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        // 幂等键已存在,说明是重复请求
        // 查询已存在的订单并返回
        return s.findOrderByIdempotencyKey(ctx, idempotencyKey)
    }

    // 3. 创建订单
    order, err := s.doCreateOrder(ctx, req)
    if err != nil {
        s.rdb.Del(ctx, lockKey) // 创建失败,释放幂等键
        return nil, err
    }

    // 4. 记录幂等键与订单的映射
    s.rdb.Set(ctx, fmt.Sprintf("idem_order:%s", idempotencyKey), order.OrderNo, 5*time.Minute)

    return order, nil
}

幂等键的生成规则:

func (s *OrderService) generateIdempotencyKey(req *CreateOrderRequest) string {
    // 基于租户ID、门店ID、菜品列表、总金额生成哈希
    data := fmt.Sprintf("%d:%d:%v:%d", req.TenantID, req.StoreID, req.Items, req.TotalAmount)
    hash := sha256.Sum256([]byte(data))
    return hex.EncodeToString(hash[:])
}

这样,同样的下单请求(同样的菜品、同样的金额),无论提交多少次,只会产生一个订单。

分布式锁:更通用的并发控制

幂等键解决的是"同一请求的重复提交",但有些场景需要更通用的并发控制。比如:同一张桌不能同时被两个收银员结账

type DistributedLock struct {
    rdb *redis.Client
}

func (l *DistributedLock) Acquire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
    return l.rdb.SetNX(ctx, fmt.Sprintf("lock:%s", key), 1, ttl).Result()
}

func (l *DistributedLock) Release(ctx context.Context, key string) error {
    return l.rdb.Del(ctx, fmt.Sprintf("lock:%s", key)).Err()
}

// 使用示例:桌台结账锁
func (s *OrderService) Checkout(ctx context.Context, tableNo string) (*Order, error) {
    lockKey := fmt.Sprintf("checkout:t:%d:table:%s", getTenantID(ctx), tableNo)

    ok, err := s.lock.Acquire(ctx, lockKey, 10*time.Second)
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, ErrTableCheckoutInProgress // 这桌正在结账
    }
    defer s.lock.Release(ctx, lockKey)

    // 执行结账逻辑
    return s.doCheckout(ctx, tableNo)
}

性能指标实测

优化前后的对比:

操作 优化前 优化后 优化手段
菜品列表查询 150ms 8ms Redis缓存 + 布隆过滤器
下单(含流水号生成) 300ms 45ms Redis原子递增 + 连接池
重复下单检测 5ms 幂等键
结账(含支付) 2000ms 800ms 连接池 + 分布式锁
缓存更新延迟 500ms 延迟双删

200ms响应?菜品查询8ms + 下单45ms + 结账800ms(含支付),API层面的处理时间远低于200ms。3秒出单?从下单到小票打印,总计约1.5秒,留足了余量。

小结

性能优化的核心思路:

  1. 连接池——别让连接创建/销毁成为瓶颈
  2. Redis原子操作——日流水号、分布式锁、幂等键,都用Redis的原子性
  3. 缓存三策略——延迟双删保一致性,布隆过滤器防穿透,互斥锁防击穿
  4. 幂等设计——防止重复下单,保证支付安全
  5. 监控先行——没有指标,优化就是盲人摸象

下一篇,我们聊多门店——从1家店到100家店,代码一行不用改。


系列导航:[龙讯餐饮SaaS架构实战]

← 上一篇:你的支付密钥我们连自己都看不到:AES-256-GCM加密实战 | 下一篇 →从1家店到100家店代码一行不用改:多门店架构设计

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景