Go 幂等设计:防重试、防重复提交、防重复消费

背景 在微服务里,重试是常态;没有幂等,重试就会制造脏数据。 常见重复来源: 客户端重复点击 网关超时后自动重试 消息队列重复投递 一种常见实现 以幂等 key 做唯一约束,先查后写或直接 UPSERT。 type IdempotencyRecord struct { Key string Status string ResultRef string } func (s *OrderService) CreateOrder(ctx context.Context, key string, req *CreateReq) (*Order, error) { if rec, ok := s.repo.FindByKey(ctx, key); ok { return s.repo.FindOrder(ctx, rec.ResultRef) } order, err := s.repo.CreateOrderWithKey(ctx, key, req) if err != nil { return nil, err } return order, nil } 总结 幂等设计本质是在失败重试下维持业务语义稳定。 关键是“唯一键 + 状态机 + 可重放结果”。 高可用系统默认会重试,幂等就是重试的安全带。

2026年4月29日 · 1 分钟 · BvBeJ

Go Worker Pool:饱和控制与降级策略

背景 很多 Go 服务都会做 worker pool,但只做“固定 worker 数量”还不够。 真正决定稳定性的,是池子打满后怎么办。 基础模型 type Job func(context.Context) error type Pool struct { jobs chan Job } func NewPool(workerN, queueN int) *Pool { p := &Pool{jobs: make(chan Job, queueN)} for i := 0; i < workerN; i++ { go func() { for job := range p.jobs { _ = job(context.Background()) } }() } return p } 关键点:拒绝策略 func (p *Pool) TrySubmit(job Job) error { select { case p.jobs <- job: return nil default: return errors.New("worker pool saturated") } } 当队列已满时,快速失败通常比无限排队更可控。 总结 worker pool 的目标不是“永不拒绝”,而是让系统在高峰时有可预测行为。 配合监控和限流,才能构成完整保护链路。 可用系统的关键是边界清晰,而不是无限扛压。

2026年4月27日 · 1 分钟 · BvBeJ

Go 服务零停机数据库 Schema 迁移实战

问题画像 线上最危险的不是“改表”,而是“业务和改表耦合发布”:应用已读新字段,但 DDL 还没完成;或者 DDL 成功了,回滚却读不懂新结构。 迁移模型:Expand -> Migrate -> Contract Expand:只做向后兼容变更(加列、加索引、双写入口)。 Migrate:后台回填与数据校验,逐步把读流量切向新字段。 Contract:确认没有旧路径后,再删旧列/旧索引。 Go 侧发布顺序 // 第一步发布:双写 + 旧读优先 func SaveOrder(ctx context.Context, o Order) error { row := model.OrderRow{ ID: o.ID, // old_total 保持兼容 OldTotal: o.Total, // new_total 为新字段 NewTotal: decimalPtr(o.Total), } return repo.Upsert(ctx, row) } // 第二步发布:读新字段,失败回退旧字段 func LoadOrderTotal(r model.OrderRow) decimal.Decimal { if r.NewTotal != nil { return *r.NewTotal } return r.OldTotal } 回填策略 按主键区间分页,避免大事务长时间占锁。 每批记录校验 checksum,把异常写入死信表。 回填作业限速,和在线业务共享数据库 QPS 预算。 观测与止损 指标:回填进度、回填错误率、慢 SQL、锁等待时间。 开关:双写开关、读路径开关、回填暂停开关。 预案:任意阶段都能回到“旧读+旧写”。 常见坑 在 Expand 阶段做非兼容 DDL(例如直接改列类型)。 回填任务不幂等,重跑会污染数据。 只关注“DDL 成功”,忽略“业务一致性成功”。 小结 零停机迁移不是一条 SQL,而是一条发布流水线。把数据库变更当成可灰度、可观测、可回滚的工程流程,风险会从“不可控事故”变成“可管理演进”。

2026年4月27日 · 1 分钟 · BvBeJ

Go 缓存一致性:更新策略与失效控制

背景 缓存能提升性能,但也最容易制造隐性 bug。 线上常见问题: 数据库已更新,缓存还是旧值 热点 key 失效瞬间把数据库打穿 多服务写入同一份数据,更新顺序错乱 在 Go 服务里,缓存一致性通常不是“技术选型”问题,而是“写路径设计”问题。 常见策略 Cache Aside 最常见模型:读先查缓存,未命中再查库并回填;写时先写库,再删缓存。 func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) { key := fmt.Sprintf("user:%d", id) if val, ok := s.cache.Get(ctx, key); ok { return decodeUser(val) } user, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } _ = s.cache.Set(ctx, key, encodeUser(user), 5*time.Minute) return user, nil } func (s *UserService) UpdateUser(ctx context.Context, user *User) error { if err := s.repo.Update(ctx, user); err != nil { return err } key := fmt.Sprintf("user:%d", user.ID) _ = s.cache.Delete(ctx, key) return nil } 这个模型简单、可靠,适合大多数业务系统。 双删策略的取舍 有些场景会用“先删缓存,再写库,延迟再删一次”。 它能降低极端并发下的脏读概率,但不是银弹。更关键的还是: 写操作是否集中在一条服务链路 有没有事件通知机制统一刷新 key 的 TTL 是否合理 避免缓存雪崩 两个实用点: TTL 加随机抖动 热点 key 做单飞保护 var g singleflight.Group func (s *UserService) GetUserWithSingleflight(ctx context.Context, id int64) (*User, error) { key := fmt.Sprintf("user:%d", id) v, err, _ := g.Do(key, func() (interface{}, error) { return s.GetUser(ctx, id) }) if err != nil { return nil, err } return v.(*User), nil } 总结 缓存一致性最重要的不是某个技巧,而是明确一致性目标: ...

2026年4月24日 · 1 分钟 · BvBeJ

Go 服务优雅重启:systemd 配合实践

背景 裸重启进程很简单,但线上会带来短暂不可用。优雅重启的目标是: 停止接收新连接 等待在途请求处理完 平滑切换到新进程 Go 侧的退出处理 srv := &http.Server{Addr: ":8080", Handler: mux} go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } }() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) <-sigCh ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() _ = srv.Shutdown(ctx) Shutdown 会先关闭监听,再等待连接收尾。 systemd 关键配置 [Service] ExecStart=/opt/app/server Restart=always RestartSec=2 TimeoutStopSec=20 KillSignal=SIGTERM KillSignal=SIGTERM 给应用机会走优雅退出逻辑 TimeoutStopSec 要大于应用 Shutdown 超时 发布建议 先在网关层摘流量 再重启实例 观察错误率与连接数回落 小结 优雅重启不是一个函数调用,而是应用与进程管理器协同设计。把退出路径做好,发布风险会明显下降。

2026年4月23日 · 1 分钟 · BvBeJ

Go Worker Pool 与背压:不靠拍脑袋定并发数

为什么会积压 很多服务写了 worker pool,但上线后仍然: 高峰时队列暴涨 平均延迟还行,P99 很差 CPU 没打满却开始超时 这通常是“入队速率 > 出队速率”的背压问题。 基础模型 type Job struct { ID string } func startPool(ctx context.Context, n int, jobs <-chan Job) { for i := 0; i < n; i++ { go func() { for { select { case <-ctx.Done(): return case job, ok := <-jobs: if !ok { return } handle(job) } } }() } } 结构不复杂,难点在参数选择。 三个观测指标 吞吐:每秒处理多少请求 延迟:P50/P95/P99 队列深度:channel 长度趋势 如果队列长期接近上限,说明处理能力不足或外部依赖抖动。 调优顺序 固定业务流量,先找单 worker 处理能力 逐步增加 worker,观察 P99 与 CPU 变化 达到拐点后停止扩容,避免锁竞争和上下文切换过量 设置拒绝策略,不让队列无限增长 一个实用策略 快速失败:队列满时直接返回可重试错误 分级队列:高优先级任务单独通道 限时执行:每个任务绑定 context 超时 总结 worker pool 不是“越大越好”。并发数本质是资源预算,必须和外部依赖能力、延迟目标一起设计。

2026年4月20日 · 1 分钟 · BvBeJ

Go Context 取消链路:别让 goroutine 泄漏

为什么这个问题总会出现 线上服务一旦有重试、聚合查询、异步回调,就很容易出现 goroutine 泄漏: 上游请求已经结束,下游协程还在跑 超时只控制了入口,没有传到内部依赖 背景任务没有退出信号 context.Context 不是万能药,但它是 Go 服务里最基础的生命周期约束。 三条硬规则 请求入口创建 context,内部只传递不重建 外部依赖调用必须接收 context 子协程要么监听 ctx.Done(),要么有明确退出条件 一个最小可用结构 func handler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond) defer cancel() user, err := userSvc.Get(ctx, "u-1001") if err != nil { http.Error(w, err.Error(), http.StatusGatewayTimeout) return } _ = json.NewEncoder(w).Encode(user) } 关键点在于:超时预算从入口建立,并传给每一层。 扇出场景的取消联动 func aggregate(ctx context.Context, id string) (Profile, error) { g, ctx := errgroup.WithContext(ctx) var base BaseInfo var score CreditScore g.Go(func() error { v, err := queryBase(ctx, id) if err != nil { return err } base = v return nil }) g.Go(func() error { v, err := queryScore(ctx, id) if err != nil { return err } score = v return nil }) if err := g.Wait(); err != nil { return Profile{}, err } return Profile{Base: base, Score: score}, nil } errgroup.WithContext 的价值是:任一分支失败,其他分支自动收到取消信号。 ...

2026年4月19日 · 1 分钟 · BvBeJ

Go gRPC 服务治理:超时、重试、熔断怎么配合

背景 很多团队从 REST 切到 gRPC 后,第一感受通常都不错: 接口定义清晰 代码生成省心 性能和序列化效率更好 但线上跑久了会发现,真正决定服务质量的不是 protobuf 文件写得多漂亮,而是这些问题处理得怎么样: 超时怎么设 失败要不要重试 下游抖动时怎么自保 连接和并发要怎么控 这些内容不处理好,gRPC 只是让调用更快地失败而已。 超时必须从调用入口就带上 Go 里最好的习惯之一,就是把超时放进 context.Context。 func (s *OrderService) GetUser(ctx context.Context, userID string) (*pb.User, error) { callCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) defer cancel() return s.userClient.GetUser(callCtx, &pb.GetUserRequest{ UserId: userID, }) } 为什么一定要带超时? 因为不带超时的 RPC,本质上就是把失败时间交给网络、内核和对端服务决定。你无法控制,也无法稳定预期。 线上更糟的是,请求可能层层调用: API -> Order Service -> User Service -> Profile Service 如果每一层都没有明确 deadline,慢请求会像雪球一样越滚越大。 重试不是默认开启就完事 很多人一看到失败就想自动重试,但重试最危险的地方在于:如果失败原因是过载,重试可能会让故障更严重。 适合重试的场景通常是: 短暂网络抖动 连接瞬时中断 明显的临时性错误 不适合盲目重试的场景: 已经超时很久的请求 非幂等写操作 下游明显处于过载状态 一个更稳的客户端封装通常像这样: func callWithRetry(ctx context.Context, fn func(context.Context) error) error { var lastErr error backoffs := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond} for _, backoff := range backoffs { if err := fn(ctx); err == nil { return nil } else { lastErr = err } select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } } return lastErr } 这里最重要的不是代码本身,而是策略: ...

2026年4月16日 · 2 分钟 · BvBeJ

Go 服务监控进阶:从指标采集到 SLI / SLO 告警

背景 很多团队的监控体系,起点都差不多: 接了 Prometheus 做了几个 Grafana 大盘 报警规则也写了一堆 结果线上一出问题,还是有两个老问题: 真故障没及时报 不重要的抖动疯狂报 本质原因通常不是“监控没接”,而是指标设计和告警语义不对。 尤其在 Go 微服务里,写 metrics 很容易,写出真正有业务意义的 metrics 并不容易。 先区分:监控数据不等于告警指标 你当然可以采很多数据,但不是所有数据都适合直接拿来告警。 比如这些指标: goroutine 数量 GC 次数 进程 RSS 请求总量 它们都很有价值,但更适合作为排障上下文,而不是一上来就触发 Pager。 真正适合做核心告警的,通常是离用户体验更近的信号。 这就是 SLI / SLO 的意义。 什么是 SLI / SLO 简单说: SLI:你用什么指标衡量服务质量 SLO:这个质量要达到什么目标 以一个 HTTP API 为例,最常见的两个 SLI 是: 可用性 成功请求比例是不是足够高。 延迟 请求耗时是不是在可接受范围内。 例如: 30 天内成功率不低于 99.9% 95% 的请求延迟低于 200ms 这两个目标就比“CPU 超过 80% 告警”更接近真实业务体验。 Go 服务里的基础指标 假设你已经有一个标准 HTTP 中间件,可以记录请求总量和耗时。 ...

2026年4月16日 · 2 分钟 · BvBeJ

Docker 多阶段构建:让你的镜像小而美

从一个真实案例说起 之前帮一个项目 Dockerize,初始镜像 800MB,每次部署慢得让人怀疑人生。后来用多阶段构建优化完,8MB,部署时间从 3 分钟变成 10 秒。 这篇文章讲讲多阶段构建的原理和实战技巧。 为什么镜像那么大 看个典型 Dockerfile: FROM golang:1.22 WORKDIR /app COPY . . RUN go mod download RUN go build -o myapp . ENTRYPOINT ["./myapp"] 问题在哪? golang:1.22 镜像本身就 800MB+,因为包含了完整的编译工具链 你的源代码、依赖缓存、编译中间产物全在里面 运行一个简单程序,凭什么需要 gcc、git、make ? 多阶段构建:原理很简单 Dockerfile 可以有多个 FROM 指令,每个阶段是独立的。前面的阶段可以复制文件到后面,最终镜像只包含最后一个阶段的内容。 # 第一阶段:构建 FROM golang:1.22 AS builder WORKDIR /app COPY . . RUN go mod download RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp . # 第二阶段:运行 FROM alpine:3.19 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY --from=builder /app/myapp . USER appuser ENTRYPOINT ["./myapp"] 关键点: ...

2026年4月15日 · 3 分钟 · BvBeJ