做电商项目,钱相关的逻辑容不得半点马虎。
之前重构一个数字商品电商平台,涉及USDT支付。金额计算必须精确,容不得半点模糊。
总结下来,有四个服务端校验是必须做的。
一、服务端重算价格
问题
前端传过来的订单总价,能不能直接用?
// 危险:直接用前端传的价格
func CreateOrder(req *CreateOrderReq) error {
order.TotalPrice = req.TotalPrice // 直接信任前端
// ...
}
不能。用户抓包改一下价格,100块的东西传0.01块,你服务器就按0.01块扣。
解决
服务端拿到商品单价,自己算一遍总价,跟前端传的对比。
func (l *CreateProductOrderLogic) CreateProductOrder(req *CreateProductOrderReq) error {
// 从数据库取商品价格
product, err := l.svcCtx.Postgres.Products.FindOne(l.ctx, req.ProductId)
// ...
// 服务端重算总价,跟客户端传值对比
totalPrice, _ := decimal.NewFromString(req.TotalPrice)
calculated := product.Price.Mul(decimal.NewFromInt(int64(req.Quantity)))
if !calculated.Equal(totalPrice) {
return l.ErrInvalidParams.Wrap(nil, "total price is not equal")
}
}
关键点:
- 价格来源:从数据库取,不是从请求里取
- 计算方式:
单价 × 数量,不是信任客户端传的总价 - 比较方式:用
decimal.Equal(),不是==
为什么用decimal不用float64?
// float64精度丢失
price := 0.1 + 0.2
fmt.Println(price) // 0.30000000000000004
// decimal精确计算
price := decimal.NewFromString("0.1")
price = price.Add(decimal.NewFromString("0.2"))
fmt.Println(price) // 0.3
金额计算必须用 shopspring/decimal 或 gf/decimal,这是底线。
还要校验什么?
价格对了还不够,还要校验:
- 商品是否在售(
OnSale) - 库存是否充足(
Total - Sold >= Quantity) - 配送选项是否合法(用户传的key是否在商品的配送选项里)
这些看似简单,但每一条都是被羊毛党薅出来的教训。
二、自定义金额校验器
问题
金额输入从接口进来是字符串(JSON的number类型对大数字有精度问题),怎么校验?
- 最多几位小数?
- 最小金额是多少?
- 最大金额是多少?
用validator默认的标签做不到"精度+范围"一体化校验。
解决
自定义一个 decimal validator:
// 使用方式:`validate:"decimal=2:0.01:10000"`
// 含义:最多2位小数,最小0.01,最大10000
type WithdrawReq struct {
Amount string `json:"amount" validate:"required,decimal=2:0.01:10000"`
}
实现:
func (v *Validator) Decimal(fl validator.FieldLevel) bool {
var value decimal.Decimal
// 支持string和float类型
switch kind := fl.Field().Kind(); kind {
case reflect.Float32, reflect.Float64:
value = decimal.NewFromFloat(fl.Field().Float())
case reflect.String:
var err error
if value, err = decimal.NewFromString(fl.Field().String()); err != nil {
return false
}
default:
return false
}
if fl.Param() == "" {
return true
}
// 解析参数:精度:最小值:最大值
params := strings.Split(fl.Param(), ":")
if len(params) > 3 {
params = params[:3]
}
// 精度校验
if len(params) > 0 {
precision, _ := strconv.Atoi(params[0])
if precision >= 1 && value.Exponent() < -int32(precision) {
return false
}
}
// 最小值
if len(params) > 1 && params[1] != "" {
min, _ := decimal.NewFromString(params[1])
if value.LessThan(min) {
return false
}
}
// 最大值
if len(params) > 2 && params[2] != "" {
max, _ := decimal.NewFromString(params[2])
if value.GreaterThan(max) {
return false
}
}
return true
}
精度校验的原理:decimal.Exponent() 返回的是10的幂次的相反数。比如 1.23 的 Exponent 是 -2,表示有2位小数。如果 Exponent() < -precision,说明小数位数超了。
举个例子:
1.234→ Exponent = -3,precision=2 → -3 < -2 → 校验失败1.23→ Exponent = -2,precision=2 → -2 < -2 → 校验通过1.2→ Exponent = -1,precision=2 → -1 < -2 → 校验通过
为什么要精度校验?
USDT最小精度是小数点后8位。如果用户传了9位小数,说明要么是爬虫精度溢出,要么是恶意输入。
三、事务内延迟删除缓存
问题
数据库更新后要删除缓存,最常见的做法:
// ❌ 事务内直接删缓存
err := db.TransactCtx(ctx, func(ctx context.Context, s sqlx.Session) error {
// 更新数据库
_, err := UpdateAccount(ctx, userId, amount)
// 直接删缓存
redis.Del("account:" + userId)
return nil
})
问题:事务提交前就删缓存
在事务提交前缓存就已经被删除了,下次读请求读不到缓存,查数据库拿到了旧数据,又写回缓存。脏数据就这么产生了。
事务操作 查询请求
更新数据库:余额 100 → 50
删除缓存 ← 此时事务还未提交,数据库里的值已经是50了
读请求:缓存没有 → 查数据库
(此时事务未提交,MySQL默认隔离级别下
读请求看不到未提交的值)
写入缓存:100
(所以这笔是100,取决于隔离级别)
事务A提交
结果:数据库 50,缓存 100 ❌
这里的核心问题是:在事务提交前就删缓存,导致并发读请求可能用旧数据污染缓存。
真正危险的场景是:
事务A 读请求B
更新数据库:余额 100 → 50
删除缓存
读请求:缓存没有 → 查数据库 → 读到旧值 100
写入缓存:100
事务A提交
结果:数据库 50,缓存 100 ❌
然后事务A继续:余额 50 → 0
但读请求命中了缓存 100,还是按 100 继续操作
解决
事务内只注册删除回调,事务提交后统一执行。
func (l *WithdrawLogic) Withdraw(req *types.WithdrawReq) (resp *types.WithdrawResp, err error) {
cacheDeletes := make([]func() error, 0)
if err := l.svcCtx.Postgres.Conn.TransactCtx(l.ctx, func(ctx context.Context, s sqlx.Session) error {
// 冻结资金,注册延迟删除回调
account, err := l.svcCtx.Postgres.Accounts.WithSession(s).AvailableToFrozen(l.ctx, userId, amount,
postgres.WithCacheLazyDelete[postgres.Accounts](func(del func() error) {
cacheDeletes = append(cacheDeletes, del)
}),
)
// 创建提现单,同样注册
withdrawal, err := l.svcCtx.Postgres.Withdrawals.WithSession(s).Insert(l.ctx, setters,
postgres.WithCacheLazyDelete[postgres.Withdrawals](func(del func() error) {
cacheDeletes = append(cacheDeletes, del)
}),
)
// 创建两条流水,同样注册
_, err = l.svcCtx.Postgres.AccountFlows.WithSession(s).Insert(...)
_, err = l.svcCtx.Postgres.AccountFlows.WithSession(s).Insert(...)
// 事务提交前统一执行缓存删除,缩短缓存删除与事务提交之间的窗口期
if err := mr.Finish(cacheDeletes...); err != nil {
return l.ErrServiceUnavailable.Wrap(err, "finish cache delete error")
}
return nil
}); err != nil {
return nil, err
}
// 事务提交完成后再删一次缓存
if err := mr.Finish(cacheDeletes...); err != nil {
// 删除失败,因为数据库已更新完成,不适合直接抛出异常,可考虑异步队列重试删除
l.Errorf("finish cache delete error: %+v", err)
}
return resp, nil
}
关键点:将缓存删除在作为事务提交前的最后一步操作来执行,最大程度缩短缓存删除与事务提交之间的窗口。在事务提交完成之后再删除一次缓存,防止在窗口期的读请求向缓存中写入了脏数据。
核心逻辑在哪?
缓存延迟删除的实现在 model 基础层里:
func (m *baseModel[T]) delCache(affected []*T, cfg *execConfig[T]) error {
delCache := func() error {
if cfg.cacheDel == nil {
return nil
}
return mr.Finish(dels...) // 并发删除所有缓存key
}
if len(cfg.cacheLazyDel) == 0 {
return delCache() // 没有延迟注册,走立即删除
}
// 有延迟注册:把删除函数通过回调传出去
for _, lazy := range cfg.cacheLazyDel {
lazy(delCache)
}
return nil // 只注册,不执行删除
}
逻辑拆解:
- 数据操作完成后,
delCache()被调用 - 如果有
cacheLazyDel,不直接删缓存,而是把删除函数通过回调传出去 - 外层(业务逻辑)收集所有回调,在事务内统一调用
- 关键:缓存删除在事务提交前执行。如果删除失败,事务回滚,不存在"数据库回滚但缓存已删"的状态
mr.Finish 并发删除
err := mr.Finish(cacheDeletes...)
go-zero 的 mr.Finish 并发执行所有缓存删除。Accounts、Withdrawals、AccountFlows 三个表的缓存同时删除,不用串行等待。
延迟删缓存能100%避免不一致吗?
不能。
即使把缓存删除放在事务最后,缓存删除到事务提交之间仍有一个微小窗口。事务提交之后的缓存删除操作也不能保证一定执行成功。更重要的是,任何缓存策略都无法100%保证缓存和数据库一致,这是分布式系统的本质限制。
真正的兜底不是消除不一致,而是让不一致不影响结果正确性。
真正的兜底:数据库原子操作
缓存不一致的影响是什么?读到了旧数据。但如果写入时不依赖缓存值,就不会出问题。
举例:用户余额扣减
// ❌ 危险:依赖缓存/数据库读取的值
account := GetAccountFromCache(userId) // 读到旧值 100
if account.Available >= amount {
account.Available -= amount // 计算:100 - 50 = 50
UpdateAccount(userId, account.Available) // 写入 50
}
// 问题:并发时两个请求都读到 100,都扣 50,结果应该是 0,实际是 50
// ✅ 安全:原子操作,不依赖读取的值
UPDATE accounts
SET available = available - $1
WHERE user_id = $2 AND available >= $1
// 数据库自己保证原子性,不会超扣
项目里的实际实现:
余额冻结
func (m *customAccountsModel) AvailableToFrozen(ctx context.Context, userId int64, amount decimal.Decimal, options ...ExecOption[Accounts]) (account *Accounts, err error) {
affected, err := m.UpdateBy(ctx, []FieldSetter{}, append(options,
// WHERE条件里带了余额检查,防止超扣
withWhere[Accounts]("user_id = $1 AND available >= $2 AND status = ANY($3)", userId, amount, pq.Array([]model.AccountStatus{model.AccountStatusActive})),
// SET里用原子操作,不是读取后计算
withSet[Accounts]("available = available - $2, frozen = frozen + $2"),
)...)
if len(affected) == 0 {
return nil, ErrNotFound // 余额不足或账户状态异常
}
return affected[0], nil
}
生成的SQL:
UPDATE accounts
SET available = available - 100, frozen = frozen + 100
WHERE user_id = 123 AND available >= 100 AND status = ANY(ARRAY[1])
关键点:
available = available - $2是原子操作available >= $2在WHERE里做乐观锁检查- 返回
affected为空说明条件不满足(余额不足或状态不对)
库存锁定
func (m *customInventoriesModel) LockStock(ctx context.Context, productId int64, quantity uint64, options ...ExecOption[Inventories]) (locked bool, err error) {
affected, err := m.UpdateBy(ctx, []FieldSetter{}, append(options,
// WHERE条件保证不会超卖
withWhere[Inventories]("product_id = $1 and total >= sold + $2", productId, quantity),
// 原子递增
withSet[Inventories]("sold = sold + $2"),
)...)
return len(affected) > 0, nil
}
生成的SQL:
UPDATE inventories
SET sold = sold + 10
WHERE product_id = 456 AND total >= sold + 10
total >= sold + quantity 保证库存足够才更新,否则返回 0 行,上层判断 locked = false。
总结:缓存是辅助,数据库才是真理
读请求 → 先查缓存 → 缓存没有 → 查数据库 → 写缓存 → 返回
写请求 → 原子更新数据库 → 删除缓存
即使缓存读到旧数据,也不影响数据正确性,因为:
- 写入时不依赖缓存值
- 数据库原子操作保证不会超扣/超卖
- 下次写操作会删除缓存,最终一致
所以延迟删缓存 + 原子操作 = 数据库永远正确,缓存只是加速读的优化手段
四个校验的关系
用户请求 → ① decimal精度校验(validator层)
→ ② 服务端重算价格(业务层)
→ ③ 事务+延迟删缓存(数据层)
→ ④ 数据库原子操作(数据库层)
- ① 是入口防线,拦截非法输入
- ② 是核心防线,防止数据篡改
- ③ 是数据防线,降低缓存不一致概率
- ④ 是兜底,确保数据正确性
四个校验覆盖从请求到落地的全链路。
总结
| 校验 | 防什么 | 关键技术 |
|---|---|---|
| 服务端重算价格 | 前端篡改价格 | decimal精确计算 + 服务端取价 |
| decimal精度校验 | 非法金额输入 | 自定义validator + Exponent精度判断 |
| 事务内延迟删缓存 | 数据不一致 | 回调注册 + 事务内统一执行 |
| 数据库原子操作 | 并发超扣/超卖 | available = available - $1 + WHERE条件检查 |
前三个是缓存层面的优化,第四个才是数据正确性的兜底。
缓存不一致不可避免,但只要写入时用原子操作,不依赖缓存值,数据库永远是对的。缓存只是加速读的手段,不是数据源。