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

从零理解分布式追踪:OpenTelemetry 实战

问题是:微服务出问题了怎么办 假设你负责一个电商系统,用户下单失败,你知道问题在哪吗? 用户 → API Gateway → Order Service → Inventory Service → Database ↓ Payment Service → 第三方支付 可能出问题的点太多了:网络超时、数据库慢查询、第三方接口挂了、代码 bug… 如果没有任何追踪手段,你只能靠经验和日志去猜。 分布式追踪就是来解决这个问题的。 核心概念:三根柱子 可观测性有三根支柱:Traces(追踪)、Metrics(指标)、Logs(日志)。这里重点聊 Traces。 Trace:一次请求的完整路径 一个 Trace 由多个 Span 组成,每个 Span 代表一个操作: Trace: 用户下单流程 ├── Span: 接收 HTTP 请求 │ ├── Span: 查询库存 (Inventory Service) │ │ ├── Span: SELECT * FROM inventory WHERE sku = ? │ ├── Span: 创建订单 (Order Service) │ │ ├── Span: INSERT INTO orders ... │ ├── Span: 调用支付 (Payment Service) │ │ └── Span: POST /api/pay 每个 Span 记录: ...

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

Go 服务限流:用令牌桶保护你的 API

背景 服务刚上线时,大家最担心的是没流量。等真有流量了,新的问题马上出现: 某个接口被脚本打爆 下游数据库扛不住瞬时峰值 重试风暴把正常请求一起拖死 这时候如果没有限流,服务再快也会被拖垮。 Go 很适合写高并发服务,限流逻辑做成中间件也很自然。今天聊一个最常见、也最实用的方案:令牌桶。 为什么是令牌桶 令牌桶的规则很简单: 系统按固定速率往桶里放令牌 请求到来时先拿一个令牌 拿到就放行,拿不到就拒绝或排队 它的好处是同时兼顾两件事: 控制平均速率 允许短时间突发 比如接口平时稳定在每秒 100 个请求,但偶尔瞬间冲到 150,只要桶里有积累的令牌,就不一定要马上拒绝。 单机场景:HTTP 中间件 Go 官方扩展库里已经有现成实现:golang.org/x/time/rate。 package main import ( "net/http" "time" "golang.org/x/time/rate" ) func RateLimitMiddleware(limiter *rate.Limiter, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !limiter.Allow() { w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte("rate limit exceeded")) return } next.ServeHTTP(w, r) }) } func main() { mux := http.NewServeMux() mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) limiter := rate.NewLimiter(rate.Every(10*time.Millisecond), 20) server := &http.Server{ Addr: ":8080", Handler: RateLimitMiddleware(limiter, mux), } _ = server.ListenAndServe() } 这段配置的含义: ...

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