Go + PostgreSQL 餐饮SaaS后端架构:从单体到多租户的实战重构

标签: Go | PostgreSQL | SaaS | 微服务 | 实战


背景

龙讯POS是一个餐饮收银系统,v1是单租户架构(Go + SQLite/MySQL),v2重构为SaaS多租户架构(Go + PostgreSQL + Redis)。

本文分享重构过程中的关键技术决策和实现细节。


一、技术栈选型

维度 v1 v2 选型理由
语言 Go 1.21 Go 1.25 延续,性能+部署简单
数据库 SQLite/MySQL PostgreSQL RLS行级安全、JSONB、DECIMAL精度
缓存 Redis 7 延迟双删、布隆过滤器、限流、Token黑名单
JWT HS256 RS256 非对称签名,公钥可分发
前端 React + MUI Vue3 + Element Plus 更好的中文生态

为什么选PostgreSQL不选MySQL?

核心原因:Row Level Security(RLS)

多租户SaaS必须在数据库层面强制隔离,PostgreSQL原生支持RLS,MySQL需要手动实现。

sql 复制代码
-- PostgreSQL RLS:3行配置完成租户隔离
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_policy ON orders
    USING (tenant_id = current_setting('app.current_tenant_id')::bigint);

二、项目结构

bash 复制代码
LongxunPos.ServerV2/
├── cmd/server/           # 入口
├── internal/
│   ├── config/           # 配置加载
│   ├── crypto/           # AES-256-GCM加解密
│   ├── handler/          # HTTP Handler(30+)
│   ├── middleware/        # 中间件(14个)
│   ├── model/            # 数据模型(27个表)
│   ├── payment/          # 支付SDK抽象层
│   ├── repository/       # 数据访问层(25个)
│   ├── service/          # 业务逻辑层(19个)
│   ├── verify/           # 核销SDK抽象层
│   └── router/           # 路由注册
├── Dockerfile
└── docker-compose.v2.yml

三、中间件链设计

请求处理流水线:

markdown 复制代码
Request → RealIP → Recoverer → Logger → Metrics → CORS
       → SecurityHeaders → MaxBodySize → RateLimit
       → JWTAuth → RBAC → TenantMiddleware → AuditMiddleware
       → Handler

关键中间件实现:

3.1 租户隔离中间件

go 复制代码
// 从JWT提取tenant_id,注入请求上下文
func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        claims := middleware.GetClaims(r)
        ctx := context.WithValue(r.Context(), tenantIDKey, claims.TenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

3.2 配额检查中间件

go 复制代码
// 创建门店/菜品/订单前检查配额
func QuotaCheck(db *gorm.DB, resource string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            tid := middleware.GetTenantID(r)
            var tenant model.Tenant
            db.First(&tenant, tid)
            switch resource {
            case "store":
                count, _ := storeRepo.Count(tid)
                if count >= tenant.MaxStores { /* 返回40004 */ }
            // ...
            }
            next.ServeHTTP(w, r)
        })
    }
}

3.3 审计中间件

go 复制代码
// 自动记录POST/PUT/DELETE操作
func AuditMiddleware(auditSvc *AuditService) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Method == "GET" { next.ServeHTTP(w, r); return }
            body := readBody(r)
            sanitized := sanitizeAuditData(body) // 脱敏password/token/secret
            beforeData := r.Context().Value(auditBeforeDataKey) // handler手动设置
            auditSvc.Log(ctx, tid, uid, r.Method, r.URL.Path, 0, beforeData, sanitized, ip)
            next.ServeHTTP(w, r)
        })
    }
}

四、支付SDK抽象层

支持3种支付渠道 + Mock模式:

go 复制代码
type PaymentSDK interface {
    Micropay(ctx context.Context, req MicropayRequest) (*MicropayResponse, error)
    Refund(ctx context.Context, req RefundRequest) (*RefundResponse, error)
    VerifyNotify(params map[string]string) (bool, error)
}

// Mock模式:开发/测试环境
type MockProvider struct{}

// 真实SDK:微信/支付宝/银联
type WechatProvider struct { config WechatConfig }
type AlipayProvider struct   { config AlipayConfig }
type UnionpayProvider struct { config UnionpayConfig }

支付回调安全

go 复制代码
func (s *PaymentService) HandleNotify(ctx context.Context, channel, notifyToken string, params map[string]string) error {
    // 1. 验签:使用官方SDK验证签名
    if !sdk.VerifyNotify(params) { return ErrInvalidSign }

    // 2. notify_token验签:HMAC-SHA256解析tenant_id
    tid := s.verifyNotifyToken(notifyToken)

    // 3. tenant_id匹配:订单必须属于该商户
    order := s.orderRepo.FindByOutTradeNo(outTradeNo, tid)

    // 4. IP白名单
    if !s.checkIPWhitelist(ctx, tid, clientIP) { return ErrIPForbidden }

    // 5. 金额校验
    if notifyAmount != order.TotalAmount { return ErrAmountMismatch }

    // 更新订单状态...
}

五、缓存策略

5.1 延迟双删

go 复制代码
func (s *CacheService) InvalidateDish(ctx context.Context, tid, dishID int64) {
    key := fmt.Sprintf("tenant:%d:dish:%d", tid, dishID)
    s.rdb.Del(ctx, key)                    // 第一次删除
    s.rdb.Set(ctx, key+":dirty", "1", 0)   // 标记脏
    time.AfterFunc(500*time.Millisecond, func() {
        s.rdb.Del(ctx, key)                // 延迟第二次删除
        s.rdb.Del(ctx, key+":dirty")
    })
}

5.2 布隆过滤器防缓存穿透

go 复制代码
func (s *CacheService) GetDish(ctx context.Context, tid, dishID int64) (*model.Dish, error) {
    // 布隆过滤器:不存在的ID直接返回
    if !s.bloom.Test([]byte(fmt.Sprintf("tenant:%d:dish:%d", tid, dishID))) {
        return nil, ErrNotFound
    }

    // 缓存命中
    if val, err := s.rdb.Get(ctx, key).Result(); err == nil {
        return unmarshal(val)
    }

    // 互斥锁防缓存击穿
    mutex := s.rdb.SetNX(ctx, key+":lock", "1", 5*time.Second)
    if !mutex.Val() {
        time.Sleep(100 * time.Millisecond)
        return s.GetDish(ctx, tid, dishID) // 重试
    }

    // 查数据库
    dish, err := s.dishRepo.FindByID(tid, dishID)
    s.rdb.Set(ctx, key, marshal(dish), randomTTL()) // 随机TTL防雪崩
    return dish, nil
}

六、金额精度处理

餐饮SaaS的金额处理是P0级问题

6.1 数据库层

sql 复制代码
amount DECIMAL(10,2) NOT NULL  -- 精确到分

6.2 Go层

go 复制代码
// 混合支付金额用string传输,避免float64精度丢失
type MixedPayRequest struct {
    OrderID     int64             `json:"order_id"`
    Channels    []ChannelPayment  `json:"channels"`
}

type ChannelPayment struct {
    Channel    string `json:"channel"`
    AmountText string `json:"amount_text"` // string! 不用float64
}

6.3 退款分配

go 复制代码
// 最后一笔 = 总退款 - 前N-1笔之和(避免累计误差)
func allocateRefund(totalRefund float64, channels []Payment) []RefundAllocation {
    var allocations []RefundAllocation
    var allocated float64
    for i, ch := range channels {
        if i == len(channels)-1 {
            allocations = append(allocations, RefundAllocation{
                Channel: ch.Channel,
                Amount:  totalRefund - allocated, // 最后一笔用差值
            })
        } else {
            ratio := ch.Amount / totalPaid
            amount := math.Round(totalRefund*ratio*100) / 100
            allocations = append(allocations, RefundAllocation{Channel: ch.Channel, Amount: amount})
            allocated += amount
        }
    }
    return allocations
}

七、性能数据

指标 数值 说明
数据库连接池 100 max / 10 idle 支持高并发
Redis连接池 50 pool / 10 min idle 限流+缓存+Token黑名单
全局限流 30 req/s 防DDoS
租户限流 200 req/min 公平分配
支付回调限流 5 req/s 防回调轰炸
文件上传限制 2MB 防大文件攻击
数据导出限制 10000条/次 防内存溢出

八、总结

从v1到v2的核心变化:

维度 v1 v2
架构 单租户 多租户SaaS
数据库 SQLite/MySQL PostgreSQL + RLS
缓存 Redis + 延迟双删 + 布隆过滤器
认证 HS256 RS256 + Token轮转 + 黑名单
安全 基础 AES-256 + RLS + 三重校验 + 哈希链审计
支付 单渠道 微信/支付宝/银联 + Mock
核销 美团/抖音 + Mock
前端 React + MUI Vue3 + Element Plus

重构不是重写,是进化。


龙讯POS V2,Go + PostgreSQL + Redis + Vue3,SaaS多租户餐饮收银系统。 如果觉得有用,点个赞👍,关注不迷路。

相关推荐
程序员爱钓鱼3 小时前
Go 开发环境安装(Windows、macOS、Linux)
后端·面试·go
ArixBit1 天前
用 Go 手撸 Agent 框架 #11:工具从哪里来——内置工具、MCP 与 Skills
go·agent
程序员爱钓鱼1 天前
为什么学习 Go?Go 能做什么?
后端·面试·go
用户095367515832 天前
在系统的学习 redis 前的疑惑
redis·go
名字还没想好☜2 天前
Go 用 errgroup 管理并发子任务:错误收敛、取消传播与限流
开发语言·后端·golang·go·并发
labixiong2 天前
TypeScript 7.0 编译器用 Go 重写,速度暴增10倍——背后到底做了什么?
前端·javascript·go
名字还没想好☜3 天前
Go 的 time.Ticker 陷阱:定时任务里被忽略的内存泄漏与正确关闭
java·数据库·golang·go·定时器
ArixBit3 天前
用 Go 手撸 Agent 框架 #10:用自己的 Runtime,做一个研究助手
go·agent
cocoCola916674 天前
validator库参数校验若干实用技巧
go