跳至主要内容

从 Gin + GORM 到 go-zero:我把配置、代码生成、RPC、缓存、日志、链路追踪和熔断串成了一个可运行 Demo

如果你已经熟悉 Gin 路由、GORM 查库,也听过 go-zero 的大名,那么这篇文章就是写给你的。

很多介绍 go-zero 的文章都会说:

  • 它有代码生成
  • 它内置微服务治理
  • 它支持 zRPC
  • 它有缓存、限流、熔断、链路追踪
  • 它比 Gin + GORM 更工程化

但真正上手时,很多人还是会卡在一堆细节上:

  • .api 文件怎么写?
  • goctl 怎么生成项目?
  • 配置怎么写?
  • MySQL 和 Redis 怎么接?
  • gRPC 服务怎么起?
  • API 网关怎么调 RPC?
  • 链路追踪怎么上报?
  • 超时到底怎么传导?
  • 熔断到底要不要自己配?

所以这篇文章不准备继续讲概念,而是直接带你串一个能跑通的最小完整 Demo

读完并跑通之后,你会得到一个包含以下能力的 go-zero 项目:

  • REST 路由:/ping/api/order/:id
  • gRPC 端点:user.User/GetUser
  • MySQL 数据访问
  • Redis 缓存
  • 日志输出
  • 链路追踪上报
  • 超时链路传导
  • 内置熔断能力
  • goctl 自动生成 API / RPC / Model

也就是说,这篇文章不是“go-zero 是什么”,而是:
怎么把 go-zero 真正跑成一个可用工程。


一、为什么从 Gin + GORM 走向 go-zero?

如果你用 Gin + GORM 写过项目,一定很熟悉这种模式:

  • 用 Gin 写路由和 Handler
  • 用 GORM 做数据库访问
  • 用 Viper 读配置
  • 用中间件处理登录鉴权
  • 用 Zap 或 Logrus 打日志
  • 自己封装 HTTP Client 调别的系统
  • 自己接 Jaeger、Consul、Prometheus、限流器、熔断器

这套方案很灵活,但问题也很明显:
所有稳定性能力都要自己拼。

比如:

  • 超时传递
  • 服务发现
  • 熔断
  • 限流
  • 降载
  • 链路追踪
  • 日志规范
  • 错误处理
  • 工程目录规范

每一个都要自己选型、封装、踩坑。项目大了以后,团队协作成本会越来越高。

go-zero 的价值,不是取代 Gin,而是把这些工程能力尽量内置化、标准化。

一句话概括:

Gin + GORM 给你自由,go-zero 给你“少踩坑的自由”。


二、这个 Demo 最终长什么样?

我们会做两个服务:

  1. order-api:REST 网关
  2. user-rpc:gRPC 用户服务

调用链如下:

客户端
  │
  ▼
order-api (REST)
  │  GET /ping
  │  GET /api/order/:id
  │
  │ zrpc client
  ▼
user-rpc (gRPC)
  │
  ├── MySQL
  └── Redis Cache

最终暴露的能力:

类型 端点 说明
REST GET /ping 健康检查
REST GET /api/order/:id 订单详情,内部调用用户服务
gRPC user.User/GetUser 查询用户,走 MySQL + Redis 缓存

这个结构非常典型:
外层一个 API 网关,内部一个 RPC 服务,再通过 MySQL + Redis 提供数据访问。


三、项目目录结构

先约定一下项目结构。后面所有命令都以项目根目录为准。

最终目录大致如下:

demo/
├── docker-compose.yaml
├── go.mod
├── api/
│   └── order.api
├── proto/
│   └── user.proto
├── sql/
│   ├── user.sql
│   └── user-data.sql
├── user-rpc/
│   ├── etc/
│   │   └── user-rpc.yaml
│   ├── internal/
│   │   ├── config/
│   │   ├── logic/
│   │   ├── model/
│   │   ├── server/
│   │   └── svc/
│   ├── user/
│   └── user.go
└── order-api/
    ├── etc/
    │   └── order-api.yaml
    ├── internal/
    │   ├── config/
    │   ├── handler/
    │   ├── logic/
    │   ├── middleware/
    │   ├── svc/
    │   └── types/
    └── order.go

不用一开始就把每个文件都背下来。
你只需要知道:

  • api/ 放 REST 接口的 DSL
  • proto/ 放 gRPC 的 proto
  • sql/ 放建表语句
  • user-rpc/ 是 RPC 服务
  • order-api/ 是 REST 服务

四、准备基础设施:MySQL、Redis、etcd、Jaeger

为了尽量减少环境差异,我们先用 Docker 把依赖都跑起来。

1. docker-compose.yaml

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: demo
    ports:
      - "3306:3306"
    volumes:
      - ./sql:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ppassword"]
      interval: 5s
      timeout: 5s
      retries: 30

  redis:
    image: redis:7
    ports:
      - "6379:6379"

  etcd:
    image: quay.io/coreos/etcd:v3.5.13
    command: etcd --advertise-client-urls=http://localhost:2379 --listen-client-urls=http://0.0.0.0:2379
    ports:
      - "2379:2379"

  jaeger:
    image: jaegertracing/all-in-one:1.57
    ports:
      - "16686:16686"
      - "14268:14268"

这里用了四个组件:

  • mysql:业务数据库
  • redis:缓存
  • etcd:服务注册与发现
  • jaeger:链路追踪界面

启动:

docker compose up -d

五、准备数据库初始化 SQL

1. sql/user.sql

这个文件后面会被 goctl model 使用。

CREATE TABLE IF NOT EXISTS user (
  id BIGINT NOT NULL AUTO_INCREMENT,
  name VARCHAR(64) NOT NULL DEFAULT '',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. sql/user-data.sql

初始化测试数据:

INSERT INTO user (id, name) VALUES (1, 'Tom'), (2, 'Jerry')
ON DUPLICATE KEY UPDATE name = VALUES(name);

如果你用 Docker 启动,MySQL 初始化时会自动执行 sql/ 目录下的脚本。


六、初始化项目与安装工具

在项目根目录执行:

go mod init demo
go get -u github.com/zeromicro/go-zero@latest
go install github.com/zeromicro/go-zero/tools/goctl@latest

检查 goctl

goctl --version

如果你要写 gRPC,还需要 protoc。macOS 可以这样安装:

brew install protobuf
goctl env check --install --verbose --force

到这里,工具链就准备好了。


七、先写 Proto:定义 gRPC 服务

我们创建一个 proto/user.proto 文件。

proto/user.proto

syntax = "proto3";

package user;

option go_package = "./user";

message GetUserReq {
  int64 id = 1;
}

message GetUserResp {
  int64 id = 1;
  string name = 2;
}

service User {
  rpc GetUser(GetUserReq) returns (GetUserResp);
}

这个文件定义了:

  • 请求参数:GetUserReq
  • 响应结果:GetUserResp
  • 服务名:User
  • 方法名:GetUser

后面会用 goctl 基于它生成 RPC 工程。


八、再写 API DSL:定义 REST 路由

go-zero 的 REST 服务通常不是手写路由,而是先写 .api 文件。

我们创建 api/order.api

api/order.api

syntax = "v1"

info(
  title: "order-api"
  desc: "订单网关示例"
  author: "you"
)

type PingResp {
  Msg string `json:"msg"`
}

type OrderInfoReq {
  Id int64 `path:"id"`
}

type OrderInfoResp {
  Id       int64  `json:"id"`
  UserId   int64  `json:"user_id"`
  UserName string `json:"user_name"`
  Amount   int64  `json:"amount"`
}

service order-api {
  @handler PingHandler
  get /ping returns (PingResp)
}

@server(
  middleware: AuthMiddleware
)
service order-api {
  @handler OrderInfoHandler
  get /api/order/:id (OrderInfoReq) returns (OrderInfoResp)
}

这里有两个接口:

  1. /ping
    不需要鉴权,用于健康检查。

  2. /api/order/:id
    需要鉴权,走 AuthMiddleware

如果你熟悉 Gin,可以把 .api 文件理解为:
把路由、参数结构体、响应结构体提前声明出来,然后让工具帮你生成样板代码。


九、用 goctl 生成工程骨架

接下来是 go-zero 非常核心的一步:代码生成。

在项目根目录执行以下命令。

1. 生成 user-rpc 工程

rm -rf user-rpc order-api
mkdir -p user-rpc

cp proto/user.proto user-rpc/user.proto

cd user-rpc
goctl rpc protoc user.proto --go_out=. --go-grpc_out=. --zrpc_out=. -style goZero
cd ..

2. 生成 order-api 工程

goctl api go -api api/order.api -dir order-api -style goZero

3. 生成带缓存的 MySQL Model

goctl model mysql ddl -src sql/user.sql -dir user-rpc/internal/model -c

重点说一下第三个命令里的 -c

-c 表示生成带缓存的 Model。
这是 go-zero 相比很多传统 ORM 很有特色的一个能力:
它会把 Cache-Aside 模式直接固化到生成代码里。

也就是说:

  • 查询时先查 Redis
  • Redis 没有再查 MySQL
  • 查完 MySQL 后回写 Redis
  • 更新或删除时自动清理缓存

这些逻辑不用你自己在业务层反复手写。


十、完善 user-rpc:数据库、缓存、日志、链路追踪

生成之后,我们开始补业务代码。


1. 配置文件:user-rpc/etc/user-rpc.yaml

Name: user-rpc
ListenOn: 0.0.0.0:9001
Timeout: 3000

Etcd:
  Hosts:
    - 127.0.0.1:2379
  Key: user.rpc

Mysql:
  DataSource: root:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=true&loc=Local

CacheRedis:
  - Host: 127.0.0.1:6379
    Type: node

Telemetry:
  Name: user-rpc
  Endpoint: http://127.0.0.1:14268/api/traces
  Sampler: 1.0
  Batcher: jaeger

Log:
  Mode: console
  Level: info

这份配置里包含了几类关键信息:

  • ListenOn:RPC 监听地址
  • Etcd:服务注册与发现
  • Mysql:数据库连接
  • CacheRedis:缓存配置
  • Telemetry:链路追踪
  • Log:日志输出

2. 配置结构体:user-rpc/internal/config/config.go

package config

import (
    "github.com/zeromicro/go-zero/core/stores/cache"
    "github.com/zeromicro/go-zero/zrpc"
)

type Config struct {
    zrpc.RpcServerConf

    Mysql struct {
        DataSource string
    }

    CacheRedis cache.CacheConf
}

这里的结构很直观:

  • zrpc.RpcServerConf:RPC 服务基础配置
  • Mysql:自定义数据库配置
  • CacheRedis:缓存配置

3. 依赖注入:user-rpc/internal/svc/servicecontext.go

package svc

import (
    "demo/user-rpc/internal/config"
    "demo/user-rpc/internal/model"

    "github.com/zeromicro/go-zero/core/stores/sqlx"
)

type ServiceContext struct {
    Config    config.Config
    UserModel model.UserModel
}

func NewServiceContext(c config.Config) *ServiceContext {
    conn := sqlx.NewMysql(c.Mysql.DataSource)

    return &ServiceContext{
        Config:    c,
        UserModel: model.NewUserModel(conn, c.CacheRedis),
    }
}

如果你写过 Gin,可以把 ServiceContext 理解成:
一个统一存放依赖的容器。

你可以把它类比成:

  • Gin 项目里的 db
  • 项目里全局注入的 service
  • 或者依赖注入框架里的 container

但在 go-zero 里,它就是一个显式传递的结构体。


4. 业务逻辑:user-rpc/internal/logic/getuserlogic.go

package logic

import (
    "context"
    "time"

    "demo/user-rpc/internal/model"
    "demo/user-rpc/internal/svc"
    "demo/user-rpc/user"

    "github.com/zeromicro/go-zero/core/logx"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type GetUserLogic struct {
    ctx    context.Context
    svcCtx *svc.ServiceContext
    logx.Logger
}

func NewGetUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserLogic {
    return &GetUserLogic{
        ctx:    ctx,
        svcCtx: svcCtx,
        Logger: logx.WithContext(ctx),
    }
}

func (l *GetUserLogic) GetUser(in *user.GetUserReq) (*user.GetUserResp, error) {
    // 演示超时传导:
    // 当访问 id=999 时,user-rpc 故意等 3 秒。
    // 而 order-api 的 RPC 客户端超时设置为 2 秒,
    // 因此上游会先收到 DeadlineExceeded。
    if in.Id == 999 {
        select {
        case <-time.After(3 * time.Second):
        case <-l.ctx.Done():
            l.Infof("user rpc context done: %v", l.ctx.Err())
            return nil, status.Error(codes.DeadlineExceeded, "user rpc timeout demo")
        }
    }

    data, err := l.svcCtx.UserModel.FindOne(l.ctx, in.Id)
    if err != nil {
        if err == model.ErrNotFound {
            l.Errorf("user id=%d not found", in.Id)
            return nil, status.Errorf(codes.NotFound, "user %d not found", in.Id)
        }

        l.Errorf("find user id=%d error: %v", in.Id, err)
        return nil, status.Error(codes.Internal, "query user failed")
    }

    l.Infof("get user from cache/db id=%d name=%s", data.Id, data.Name)

    return &user.GetUserResp{
        Id:   data.Id,
        Name: data.Name,
    }, nil
}

这里有几个关键点:

  1. l.svcCtx.UserModel.FindOne

    • 这是 goctl model 生成的查询方法
    • 自带缓存逻辑
  2. logx

    • go-zero 的日志组件
    • 会自动带上上下文信息
  3. status.Error

    • 返回标准 gRPC 错误
    • 后续网关可以把这些错误映射成 HTTP 状态码
  4. id=999 的演示逻辑

    • 用于演示超时传导
    • 后面会专门验证它

5. 服务入口:user-rpc/user.go

package main

import (
    "flag"
    "fmt"

    "demo/user-rpc/internal/config"
    "demo/user-rpc/internal/server"
    "demo/user-rpc/internal/svc"
    "demo/user-rpc/user"

    "github.com/zeromicro/go-zero/core/conf"
    "github.com/zeromicro/go-zero/zrpc"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

var configFile = flag.String("f", "etc/user-rpc.yaml", "the config file")

func main() {
    flag.Parse()

    var c config.Config
    conf.MustLoad(*configFile, &c)

    ctx := svc.NewServiceContext(c)

    s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
        user.RegisterUserServer(grpcServer, server.NewUserServer(ctx))
        reflection.Register(grpcServer)
    })
    defer s.Stop()

    fmt.Printf("user rpc server is running at %s\n", c.ListenOn)
    s.Start()
}

到这里,user-rpc 就完整了。

它包含:

  • 配置
  • 数据库
  • 缓存
  • 日志
  • gRPC 服务
  • 链路追踪上报
  • 超时处理示例

十一、完善 order-api:路由、中间件、RPC 调用、错误映射

接下来写网关层。


1. 配置文件:order-api/etc/order-api.yaml

Name: order-api
Host: 0.0.0.0
Port: 8888
Timeout: 3000
MaxBytes: 1048576

UserRpc:
  Etcd:
    Hosts:
      - 127.0.0.1:2379
    Key: user.rpc
  Timeout: 2000
  NonBlock: true

Telemetry:
  Name: order-api
  Endpoint: http://127.0.0.1:14268/api/traces
  Sampler: 1.0
  Batcher: jaeger

Log:
  Mode: console
  Level: info

这里最重要的是超时配置:

  • order-api 自身超时:3000ms
  • user-rpc 超时:2000ms

这会在后面形成一个非常典型的超时传导链路。


2. 配置结构体:order-api/internal/config/config.go

package config

import (
    "github.com/zeromicro/go-zero/rest"
    "github.com/zeromicro/go-zero/zrpc"
)

type Config struct {
    rest.RestConf

    UserRpc zrpc.RpcClientConf
}

3. 依赖注入:order-api/internal/svc/servicecontext.go

这里为了降低跨服务生成代码的耦合,我们直接使用:

  • zrpc.MustNewClient
  • user.NewUserClient

而不是强依赖 goctl 生成的 userclient 包。

这样做的好处是:

  • 仍然保留 go-zero 的服务发现、负载均衡、超时、熔断、链路追踪能力
  • 代码更容易跑通,也更容易理解
package svc

import (
    "demo/order-api/internal/config"
    "demo/user-rpc/user"

    "github.com/zeromicro/go-zero/zrpc"
)

type ServiceContext struct {
    Config      config.Config
    UserRpc     user.UserClient
    UserRpcConn zrpc.Client
}

func NewServiceContext(c config.Config) *ServiceContext {
    userRpcConn := zrpc.MustNewClient(c.UserRpc)

    return &ServiceContext{
        Config:      c,
        UserRpc:     user.NewUserClient(userRpcConn.Conn()),
        UserRpcConn: userRpcConn,
    }
}

4. Ping 逻辑:order-api/internal/logic/pinglogic.go

package logic

import (
    "context"

    "demo/order-api/internal/svc"
    "demo/order-api/internal/types"

    "github.com/zeromicro/go-zero/core/logx"
)

type PingLogic struct {
    ctx    context.Context
    svcCtx *svc.ServiceContext
    logx.Logger
}

func NewPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PingLogic {
    return &PingLogic{
        ctx:    ctx,
        svcCtx: svcCtx,
        Logger: logx.WithContext(ctx),
    }
}

func (l *PingLogic) Ping() (*types.PingResp, error) {
    return &types.PingResp{
        Msg: "pong",
    }, nil
}

5. 订单逻辑:order-api/internal/logic/orderinfologic.go

package logic

import (
    "context"

    "demo/order-api/internal/svc"
    "demo/order-api/internal/types"
    "demo/user-rpc/user"

    "github.com/zeromicro/go-zero/core/logx"
)

type OrderInfoLogic struct {
    ctx    context.Context
    svcCtx *svc.ServiceContext
    logx.Logger
}

func NewOrderInfoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OrderInfoLogic {
    return &OrderInfoLogic{
        ctx:    ctx,
        svcCtx: svcCtx,
        Logger: logx.WithContext(ctx),
    }
}

func (l *OrderInfoLogic) OrderInfo(req *types.OrderInfoReq) (*types.OrderInfoResp, error) {
    var userID int64

    switch req.Id {
    case 999:
        // 用于演示超时传导
        userID = 999
    case 404:
        // 用于演示 gRPC NotFound
        userID = 404
    default:
        // 简单 mock:订单 1 -> 用户 2,订单 2 -> 用户 1,依此类推
        userID = req.Id%2 + 1
    }

    resp, err := l.svcCtx.UserRpc.GetUser(l.ctx, &user.GetUserReq{
        Id: userID,
    })
    if err != nil {
        l.Errorf("call user rpc GetUser id=%d error: %v", userID, err)
        return nil, err
    }

    return &types.OrderInfoResp{
        Id:       req.Id,
        UserId:   resp.Id,
        UserName: resp.Name,
        Amount:   9900,
    }, nil
}

这段代码模拟的是:

  • 根据订单 ID 查用户
  • 再从用户服务拿用户信息
  • 最后拼订单详情返回

6. 中间件:order-api/internal/middleware/authmiddleware.go

如果 goctl 已经生成了这个文件,直接替换成下面的实现。

package middleware

import (
    "net/http"
)

type AuthMiddleware struct{}

func NewAuthMiddleware() *AuthMiddleware {
    return &AuthMiddleware{}
}

func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Authorization") != "Bearer demo-token" {
            w.Header().Set("Content-Type", "application/json; charset=utf-8")
            w.WriteHeader(http.StatusUnauthorized)
            _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
            return
        }

        next(w, r)
    }
}

这个中间件很简单:

  • 只允许 Authorization: Bearer demo-token
  • 否则返回 401

7. 服务入口:order-api/order.go

package main

import (
    "flag"
    "fmt"
    "net/http"

    "demo/order-api/internal/config"
    "demo/order-api/internal/handler"
    "demo/order-api/internal/svc"

    "github.com/zeromicro/go-zero/core/conf"
    "github.com/zeromicro/go-zero/rest"
    "github.com/zeromicro/go-zero/rest/httpx"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

var configFile = flag.String("f", "etc/order-api.yaml", "the config file")

func main() {
    flag.Parse()

    var c config.Config
    conf.MustLoad(*configFile, &c)

    httpx.SetErrorHandler(func(err error) (int, any) {
        if s, ok := status.FromError(err); ok {
            switch s.Code() {
            case codes.NotFound:
                return http.StatusNotFound, map[string]string{
                    "error": s.Message(),
                }
            case codes.DeadlineExceeded:
                return http.StatusGatewayTimeout, map[string]string{
                    "error": "upstream timeout",
                }
            default:
                return http.StatusInternalServerError, map[string]string{
                    "error": s.Message(),
                }
            }
        }

        return http.StatusBadRequest, map[string]string{
            "error": err.Error(),
        }
    })

    server := rest.MustNewServer(c.RestConf)
    defer server.Stop()

    ctx := svc.NewServiceContext(c)
    handler.RegisterHandlers(server, ctx)

    fmt.Printf("order api is running at %s:%d\n", c.Host, c.Port)
    server.Start()
}

这里特意加了一个统一错误处理:

  • codes.NotFound -> HTTP 404
  • codes.DeadlineExceeded -> HTTP 504

这样前端拿到的错误会更友好。


十二、整理依赖

在项目根目录执行:

go mod tidy

如果你发现 user-rpc/userclient 目录里的生成代码有 import 路径问题,可以先删掉它:

rm -rf user-rpc/userclient

因为本文网关调用部分用的是:

  • zrpc.MustNewClient
  • user.NewUserClient(...)

所以不依赖 userclient 也能完整跑通。


十三、启动服务

建议开两个终端。

1. 启动 user-rpc

cd user-rpc
go run user.go -f etc/user-rpc.yaml

正常会看到:

user rpc server is running at 0.0.0.0:9001

2. 启动 order-api

cd order-api
go run order.go -f etc/order-api.yaml

正常会看到:

order api is running at 0.0.0.0:8888

十四、验证接口

1. 健康检查

curl -i http://127.0.0.1:8888/ping

返回:

{"msg":"pong"}

2. 不带 token 访问受保护接口

curl -i http://127.0.0.1:8888/api/order/1

返回:

{"error":"unauthorized"}

HTTP 状态码为 401。


3. 带 token 访问订单详情

curl -i -H "Authorization: Bearer demo-token" http://127.0.0.1:8888/api/order/1

返回类似:

{
  "id": 1,
  "user_id": 2,
  "user_name": "Jerry",
  "amount": 9900
}

再试:

curl -i -H "Authorization: Bearer demo-token" http://127.0.0.1:8888/api/order/2

返回类似:

{
  "id": 2,
  "user_id": 1,
  "user_name": "Tom",
  "amount": 9900
}

4. 模拟用户不存在

curl -i -H "Authorization: Bearer demo-token" http://127.0.0.1:8888/api/order/404

返回:

{
  "error": "user 404 not found"
}

HTTP 状态码为 404。


5. 模拟超时传导

curl -i -H "Authorization: Bearer demo-token" http://127.0.0.1:8888/api/order/999

返回:

{
  "error": "upstream timeout"
}

HTTP 状态码为 504。

这个例子说明:

  • order-api 总超时是 3 秒
  • user-rpc 超时是 2 秒
  • user-rpc 故意等 3 秒
  • 最终上游先超时,而不是无限等待

这就是非常典型的链路超时传导


十五、验证缓存是否生效

第一次请求某个用户时,通常会查 MySQL。
再次请求同一个用户时,通常会走 Redis。

你可以用下面的命令简单看看缓存 key:

redis-cli --scan --pattern '*user*'

不同版本生成代码的 key 格式可能略有差异,但一般会包含表名和主键,例如类似:

cache:user:1

与此同时,你会在 user-rpc 的日志里看到类似:

get user from cache/db id=1 name=Tom

这说明数据库、缓存、日志这三块已经串起来了。


十六、验证链路追踪

打开 Jaeger:

http://127.0.0.1:16686

然后访问几次接口,例如:

curl -H "Authorization: Bearer demo-token" http://127.0.0.1:8888/api/order/1

在 Jaeger 中,你通常可以看到:

  • order-api 的 HTTP span
  • user-rpc 的 gRPC span
  • 同一条调用链被串起来

这说明两件事已经生效:

  1. 链路追踪上报成功
  2. 跨服务上下文传导成功

这也是很多 Gin 项目要自己埋点才能实现的部分。


十七、关于熔断、限流、降载,你该怎么理解?

很多文章一讲到这里,就会开始堆概念。
但在这个 Demo 里,你可以换一个更实际的角度理解。

1. 熔断

go-zero 的熔断能力是内置在调用链里的,尤其是在 RPC 调用中默认生效。
它不是你手工 new 一个 breaker 实例,再去包装每个调用。

在这个 Demo 里,如果你想感受熔断,可以反复请求:

curl -H "Authorization: Bearer demo-token" http://127.0.0.1:8888/api/order/999

制造大量超时或失败,然后观察客户端行为。

重点不是“自己写一个熔断器”,而是:
稳定性能力被框架默认提供了。


2. 限流与降载

本文 Demo 没有单独展开限流配置,但 go-zero 本身提供:

  • 自适应降载
  • 限流能力
  • 请求体积限制
  • 超时控制

比如 order-api.yaml 中的:

MaxBytes: 1048576

就是非常典型的入口保护配置。


十八、这个 Demo 到底串通了哪些能力?

到这里,你已经不是“知道 go-zero 有哪些特性”,而是真的把它们跑起来了。

我们回顾一下:

1. 配置管理

  • yaml
  • conf.MustLoad
  • 强类型结构体绑定

2. 自动代码生成

  • goctl api go
  • goctl rpc protoc
  • goctl model mysql ddl -c

3. 路由

  • .api 文件声明路由
  • 自动生成 handler / routes

4. 数据库

  • goctl model
  • sqlx
  • MySQL CRUD

5. 缓存

  • 自动生成 Cache-Aside 逻辑
  • 查询、回写、失效都由生成代码处理

6. 日志

  • logx
  • access log
  • logic 层日志

7. gRPC + proto

  • proto 定义服务
  • goctl rpc protoc 生成 RPC 工程骨架

8. 熔断

  • zrpc 调用链内置治理能力
  • 不需要手工拼第三方熔断库

9. 链路追踪上报

  • Telemetry
  • Jaeger 查看调用链

10. 链路传导

  • context 一路传递
  • HTTP -> RPC -> MySQL/Redis
  • 超时时间逐级传导

十九、如果你正准备从 Gin + GORM 迁移,我的建议是渐进式

不建议一上来推倒重来。

更稳的路径是这样的:

第一步:新模块先试点

新功能模块用 go-zero 的 .api + goctl model 写一遍。
先让团队熟悉:

  • .api 语法
  • 生成目录结构
  • logic / handler / svc 分层

第二步:网关先收敛

用 go-zero 的 rest.Server 做统一 API 网关。
老服务可以先挂在网关后面,逐步改造。

第三步:数据层平滑迁移

ServiceContext 里可以同时放:

  • *gorm.DB
  • goctl model 生成的 Model

也就是说,GORM 和 go-zero 的数据访问方式可以共存。

第四步:治理能力逐步接入

先上默认能力:

  • 超时
  • 熔断
  • 降载

再做:

  • 服务发现
  • RPC 化
  • 链路追踪

这样迁移成本最低。


二十、最后总结一下

如果这篇文章你只记一句话,那就是:

Gin + GORM 给你自由,go-zero 给你“少踩坑的自由”。

Gin 很轻,GORM 很顺手,但在真实生产环境里,你迟早要补上这些能力:

  • 配置管理
  • 日志规范
  • 超时传递
  • 服务发现
  • 熔断限流
  • 链路追踪
  • 统一工程结构

go-zero 的价值,就是把这些东西尽量变成默认能力,而不是让你每次都从零拼装。

而这篇文章给你的,不是一组孤立概念,而是一个完整闭环:

  • 有路由
  • 有 RPC
  • 有数据库
  • 有缓存
  • 有日志
  • 有链路追踪
  • 有超时传导
  • 有熔断入口

只要你把这个 Demo 跑通一遍,再回头去看 go-zero 官方文档,理解会完全不一样。


附:本文涉及的核心命令速查

# API 服务生成
goctl api go -api order.api -dir order-api -style goZero

# RPC 服务生成
goctl rpc protoc user.proto --go_out=. --go-grpc_out=. --zrpc_out=. -style goZero

# 带缓存 Model 生成
goctl model mysql ddl -src sql/user.sql -dir user-rpc/internal/model -c

# 环境检查与安装
goctl env check --install --verbose --force

# 整理依赖
go mod tidy

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。