标签: 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多租户餐饮收银系统。 如果觉得有用,点个赞👍,关注不迷路。