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秒,留足了余量。
小结
性能优化的核心思路:
- 连接池——别让连接创建/销毁成为瓶颈
- Redis原子操作——日流水号、分布式锁、幂等键,都用Redis的原子性
- 缓存三策略——延迟双删保一致性,布隆过滤器防穿透,互斥锁防击穿
- 幂等设计——防止重复下单,保证支付安全
- 监控先行——没有指标,优化就是盲人摸象
下一篇,我们聊多门店——从1家店到100家店,代码一行不用改。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:你的支付密钥我们连自己都看不到:AES-256-GCM加密实战 | 下一篇 →从1家店到100家店代码一行不用改:多门店架构设计
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景