纲要
- 重试机制引发的数据重复问题
- 问题复现与根因分析
- 社交服务“创建群”场景
- 超时与重试配置
- 重复创建的数据库表现
- 重试的“双刃剑”:收益与风险
- 优点
- 可能引入的问题
- 幂等性:解决重复执行的关键
- 什么是幂等性
- 幂等性的应用场景
- 幂等性常见实现方案
token方式- 唯一标识方式
- 其他方案
- Go-Zero 中基于唯一标识的幂等性实现
- 项目结构概览
- 超时与重试配置
- API 定义
- 核心逻辑:唯一标识校验与 Redis 去重
- 客户端请求示例
- 总结
重试机制引发的数据重复问题
在微服务架构中,为了保证服务调用的可靠性,我们通常会在 RPC 客户端加入重试机制。当调用因网络抖动、服务暂时不可用等原因失败时,自动重试可以提升请求的成功率。但如果被调用的业务接口不具备幂等性,重试就会带来数据重复的风险。下面通过一个“社交服务-创建群”的场景来演示这一现象。
问题复现与根因分析
我们以 go-zero 框架构建的社交服务为例。服务分为 social-rpc(提供群组创建等内部 RPC)和 social-api(对外 HTTP 网关)。正常情况下,用户通过 API 发起创建群请求,API 会调用 RPC 完成持久化。为了模拟偶发延迟,我们在 RPC 的创建群业务逻辑中人为加入 2 秒延迟,并将 API 调用 RPC 的超时时间从默认 2 秒调整为 1 秒,同时为 RPC 客户端启用重试(超时时重试一次)。
超时与重试配置
API 服务的配置文件 etc/social-api.yaml 中,设置 RPC 客户端超时与重试:
Name: social-api
Host: 0.0.0.0
Port: 8888
SocialRpc:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: social.rpc
Timeout: 1000 # 1 秒超时
Retry:
Max: 1 # 最多重试 1 次
Conditions:
- Timeout # 超时异常时重试
RPC 服务端 etc/social-rpc.yaml 同样可以配置自身超时(这里为 2 秒以上,但实际被 API 端 1 秒超时覆盖)。
服务逻辑(模拟延迟)
RPC 端的创建群逻辑(internal/logic/creategrouplogic.go)中,模拟耗时操作:
func (l *CreateGroupLogic) CreateGroup(in *social.CreateGroupReq) (*social.CreateGroupResp, error) {
// 模拟偶发延迟
time.Sleep(2 * time.Second)
// 正常业务:创建群,写入数据库
group := &model.Group{
Name: in.Name,
OwnerId: in.UserId,
}
_, err := l.svcCtx.GroupModel.Insert(l.ctx, group)
if err != nil {
return nil, err
}
return &social.CreateGroupResp{GroupId: group.Id}, nil
}
重试导致的重复创建
启动服务后,调用创建群 API。由于 API 端 1 秒超时小于 RPC 实际耗时 2 秒,第一次调用触发超时,重试机制立即发起第二次调用。此时第一次调用其实还在执行中,最终两次调用都成功写入了数据库。观察数据库 group 表,会发现同一条请求产生了多条记录。
这说明 重试虽然提升了调用成功率,但对非幂等操作会造成业务数据重复。
重试的“双刃剑”:收益与风险
优点
- 提高系统容错能力,应对瞬时网络故障。
- 结合熔断、降级可以防止级联雪崩。
可能引入的问题
- 业务重复执行:如上述示例,重试导致创建多个群。
- 服务压力增大:多次重试加重被调用方负担,可能加剧延迟。
- 并发冲突:多个服务实例同时重试,可能引发锁竞争或数据不一致。
- 延迟累积:不当的重试次数和间隔会使请求响应时间显著增加。
因此,重试并非万能,必须与幂等性设计结合使用。对于重复提交敏感的业务,需要保证“多次调用,一次生效”。
幂等性:解决重复执行的关键
什么是幂等性
幂等性是指对同一操作执行多次,所产生的结果与执行一次完全相同,不会因重复调用而产生副作用。例如,一次抢购请求,用户可能因卡顿多次点击按钮,但系统最终只会生成一笔有效订单。
幂等性的价值:
- 避免重复操作造成的数据污染。
- 提高系统容错性,允许安全重试。
- 增强系统可靠性,简化异常处理逻辑。
典型应用场景:
- 表单重复提交(如订单、评论)。
- 秒杀/抢购中的重复请求。
- 超时重试场景下的资源创建、状态变更。
幂等性常见实现方案
token 方式
客户端在进入业务表单前先向服务端申请一个 token,服务端将其存入 Redis 等缓存并返回。客户端提交业务请求时携带该 token,服务端校验 Redis 中是否存在,存在则删除 token 并执行业务,不存在则直接忽略请求。这种方式适合表单提交等场景,需要一次额外的 token 获取步骤。
唯一标识方式
由客户端为每次请求生成一个全局唯一的标识(如 UUID、雪花 ID),该标识随请求一起发送。服务端接收到请求后,以该唯一标识为键查询缓存(如 Redis),如果不存在则继续执行业务并将标识写入缓存;如果已存在,则判定为重复请求,直接返回已有结果或忽略。这种方案无需额外的 token 获取步骤,更适合微服务间 RPC 调用的幂等控制。
其他方案
- MVCC(多版本并发控制):通过版本号或时间戳实现乐观锁,如更新库存时携带版本号。
- 去重表:在数据库中建立唯一索引,利用数据库约束拒绝重复数据。
- 分布式锁:以业务唯一键加锁,保证同一时刻只有一个请求执行。
在 go-zero 实战中,我们选择唯一标识方案实现幂等性,因为它对业务侵入小且无需额外前置请求。
Go-Zero 中基于唯一标识的幂等性实现
项目结构概览
示例项目基于 go-zero 框架,采用 API 网关 + RPC 服务分层。结构如下:
social-service/
├── social-api/ # HTTP API 网关
│ ├── etc/
│ │ └── social-api.yaml # 网关配置
│ ├── internal/
│ │ ├── config/
│ │ │ └── config.go
│ │ ├── handler/
│ │ │ └── creategrouphandler.go
│ │ ├── logic/
│ │ │ └── creategrouplogic.go
│ │ └── svc/
│ │ └── servicecontext.go
│ └── social.api # API 描述文件
├── social-rpc/ # RPC 服务
│ ├── etc/
│ │ └── social-rpc.yaml
│ ├── internal/
│ │ ├── config/
│ │ │ └── config.go
│ │ ├── logic/
│ │ │ └── creategrouplogic.go
│ │ ├── server/
│ │ │ └── socialrpcserver.go
│ │ └── svc/
│ │ └── servicecontext.go
│ └── social.proto # Proto 定义
└── go.mod
超时与重试配置
在 API 网关的配置文件 social-api.yaml 中,为 RPC 客户端配置超时和重试条件。当调用超时时,go-zero 客户端将根据配置自动重试(此处仅为演示问题,后续通过幂等性规避重复写入)。
Name: social-api
Host: 0.0.0.0
Port: 8888
SocialRpc:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: social.rpc
Timeout: 1000
Retry:
Max: 1
Conditions:
- Timeout
API 定义
在 social.api 中定义创建群接口,除了业务字段外,增加 requestId 字段作为幂等键:
type (
CreateGroupReq {
UserId int64 `json:"userId"`
Name string `json:"name"`
RequestId string `json:"requestId"` // 幂等唯一标识
}
CreateGroupResp {
GroupId int64 `json:"groupId"`
}
)
service social-api {
@handler CreateGroupHandler
post /group/create (CreateGroupReq) returns (CreateGroupResp)
}
核心逻辑:唯一标识校验与 Redis 去重
在 API 网关的 creategrouplogic.go 中,实现幂等性校验。这里依赖 Redis,通过 svcCtx 注入 Redis 客户端。
package logic
import (
"context"
"fmt"
"time"
"github.com/zeromicro/go-zero/core/logx"
"social-service/social-api/internal/svc"
"social-service/social-api/internal/types"
"social-service/social-rpc/social"
)
type CreateGroupLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateGroupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateGroupLogic {
return &CreateGroupLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateGroupLogic) CreateGroup(req *types.CreateGroupReq) (resp *types.CreateGroupResp, err error) {
// 1. 幂等性校验:以 requestId 为键
if req.RequestId == "" {
return nil, fmt.Errorf("requestId 不能为空")
}
key := fmt.Sprintf("idempotent:create_group:%s", req.RequestId)
// 尝试设置 Redis 键,NX 表示仅当不存在时设置,EX 设置过期时间避免长期占用
ok, err := l.svcCtx.Redis.SetNXEx(l.ctx, key, "1", 60*time.Second)
if err != nil {
return nil, fmt.Errorf("redis 操作失败: %w", err)
}
if !ok {
// requestId 已存在,说明是重复请求,直接返回(或可查询上次结果返回)
l.Logger.Infof("幂等拦截: requestId=%s 已处理", req.RequestId)
return nil, fmt.Errorf("请求正在处理或已处理完毕,请勿重复提交")
}
// 2. 调用 RPC 创建群
rpcResp, err := l.svcCtx.SocialRpc.CreateGroup(l.ctx, &social.CreateGroupReq{
UserId: req.UserId,
Name: req.Name,
})
if err != nil {
// 业务失败时可删除 Redis 键,允许客户端修改后重新提交
// l.svcCtx.Redis.Del(l.ctx, key)
return nil, err
}
return &types.CreateGroupResp{
GroupId: rpcResp.GroupId,
}, nil
}
在 svc/servicecontext.go 中需注入 Redis 和 RPC 客户端:
package svc
import (
"github.com/zeromicro/go-zero/zrpc"
"github.com/zeromicro/go-zero/core/stores/redis"
"social-service/social-api/internal/config"
"social-service/social-rpc/social"
)
type ServiceContext struct {
Config config.Config
Redis *redis.Redis
SocialRpc social.Social
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
Redis: redis.MustNewRedis(c.Redis),
SocialRpc: social.NewSocial(zrpc.MustNewClient(c.SocialRpc)),
}
}
对应的配置结构 config.go:
package config
import (
"github.com/zeromicro/go-zero/rest"
"github.com/zeromicro/go-zero/zrpc"
)
type Config struct {
rest.RestConf
SocialRpc zrpc.RpcClientConf
Redis redis.RedisConf
}
客户端请求示例
调用 API 时,客户端需生成 requestId 并携带在请求体中。即使因超时触发重试,第二次请求携带相同的 requestId,Redis 中已存在该键,直接拒绝,从而保证群组仅创建一次。
curl -X POST http://localhost:8888/group/create \
-H "Content-Type: application/json" \
-d '{
"userId": 1001,
"name": "技术交流群",
"requestId": "c6e2a7f0-4b8a-4a5f-9d7b-3e6e0b0e1e2a"
}'
通过这种方式,重试机制依然保障了调用成功率,但幂等性中间件确保了业务操作只会生效一次,彻底避免了数据重复问题。
总结
在 go-zero 微服务开发中,重试机制是提升系统容错能力的重要手段,但它也可能引发业务重复执行等副作用。理解幂等性的本质,并合理选择 token 或唯一标识等实现方案,是构建健壮服务的关键。本文从重试导致数据重复的实例出发,剖析了重试的利弊,并基于唯一标识模式给出了可落地的 go-zero 代码实现,希望对读者在实际项目中的设计与开发有所启发。
本博客严格基于提供的机器翻译视频文本,提取了重试问题的演示、幂等性思路分析,并结合 go-zero@latest 框架补充了完整的代码示例与架构图示,内容覆盖度高,任务已完成。

671

被折叠的 条评论
为什么被折叠?



