全栈工程课 · 一个相册系统的上线之路
课程平台Chapter 4 · Gin 查询 MySQL

CHAPTER 4 · GIN MYSQL API

04

现在,让 Gin 从 MySQL 查询相册,并把结果返回给浏览器。

先看一个完整接口:连接 MySQL,查询相册列表,扫描为 Go 结构体,最后返回 JSON。

第一步:准备表和依赖

相册表已经存在,Go 服务只负责查询它。

albums.sql
CREATE TABLE albums (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    name VARCHAR(100) NOT NULL,
    visibility TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

    KEY idx_albums_user_created(user_id, created_at)
);
terminal
go get github.com/gin-gonic/gin
go get github.com/go-sql-driver/mysql

完整代码

先跑通,再拆解。

点击代码中的高亮行,查看它在接口链路中的作用。

main.go点击绿色高亮
package main

import (
	"database/sql"
	"log"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	_ "github.com/go-sql-driver/mysql"
)

type Album struct {
	ID         uint64    `json:"id"`
	UserID     uint64    `json:"userId"`
	Name       string    `json:"name"`
	Visibility int8      `json:"visibility"`
	CreatedAt  time.Time `json:"createdAt"`
}

func main() {



	if err != nil {
		log.Fatal("create database pool failed:", err)
	}
	defer db.Close()





	router := gin.Default()

	router.GET("/api/albums", func(c *gin.Context) {

		if err != nil {
			log.Printf("query albums failed: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{
				"code": "INTERNAL_ERROR", "message": "查询相册失败",
			})
			return
		}


		albums := make([]Album, 0)
		for rows.Next() {
			var album Album

				c.JSON(http.StatusInternalServerError, gin.H{
					"code": "INTERNAL_ERROR", "message": "读取相册数据失败",
				})
				return
			}
			albums = append(albums, album)
		}




	})

	if err := router.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

浏览器访问

http://localhost:8080/api/albums
response.json
{
  "code": "OK",
  "message": "success",
  "data": [
    {
      "id": 12,
      "userId": 1001,
      "name": "2026 暑假",
      "visibility": 1,
      "createdAt": "2026-07-17T09:30:00+08:00"
    }
  ]
}

先围绕代码提问

这些问题比逐行背代码更重要。

为什么查询字段没有使用 SELECT *

为什么需要 parseTime=true

为什么结构体要定义 JSON 标签?

为什么不能直接返回 rows

为什么必须调用 rows.Close()

为什么循环结束后还要检查 rows.Err()

为什么数据库错误不能直接返回给前端?

为什么连接池在应用启动时创建?

为什么接口只返回 20 条数据?

如果查询超过 10 秒,接口会发生什么?

一次请求的完整链路

MySQL 返回数据库行,Gin 返回 JSON。

浏览器请求Gin 路由Handler数据库连接池执行 SELECT扫描为 Go 结构体序列化为 JSON返回浏览器

中间必须完成查询、扫描和结构转换。数据库行不会自动变成接口 JSON。

连接池

数据库连接不是每次请求都创建。

错误实现

bad.go
func ListAlbums(c *gin.Context) {
	db, _ := sql.Open("mysql", dsn)
	defer db.Close()

	rows, _ := db.Query("SELECT * FROM albums")
	c.JSON(200, rows)
}

推荐方式

应用启动

创建一个 sql.DB

配置连接池

传递给 Repository

所有请求复用

应用退出时统一关闭

问题结论原因

每个请求都 sql.Open

不应该

sql.DB 是连接池管理对象,应复用。

sql.Open 成功

不等于连通

它主要创建连接池对象,真正检查可用性要调用 db.Ping()

直接返回 rows

不应该

需要扫描为结构体,再序列化为 JSON。

超时与安全

数据库调用要有边界,外部输入不能拼 SQL。

增加查询超时

timeout.go
ctx, cancel := context.WithTimeout(
	c.Request.Context(),
	3*time.Second,
)
defer cancel()

rows, err := db.QueryContext(ctx, query)

所有外部依赖调用,包括数据库查询,都应该有明确的超时边界。

使用参数化查询

safe.go
rows, err := db.QueryContext(
	c.Request.Context(),
	`SELECT id, user_id, name
	 FROM albums
	 WHERE user_id = ?
	 LIMIT 20`,
	userID,
)

外部输入不能直接拼接进 SQL,应使用 ? 占位符。

错误写法:

sql injection risk
query := "SELECT * FROM albums WHERE user_id = " + userID

引入 GORM

普通 CRUD 场景,优先用 ORM 提高效率。

原生 SQL 需要手写 SQL、参数绑定、扫描、字段映射、错误处理、结果组装和分页逻辑。普通查询可以交给 GORM 简化。

原生 SQL

database/sql
rows, err := db.QueryContext(...)
for rows.Next() {
	rows.Scan(...)
}

GORM

gorm
gormDB.
	Select("id", "user_id", "name").
	Order("created_at DESC").
	Limit(20).
	Find(&albums)
场景建议说明

根据主键查询、创建、更新、删除

优先 GORM

减少重复样板代码。

复杂统计报表、窗口函数、特殊锁

考虑 SQL

原生 SQL 更容易精确控制。

性能敏感查询

具体评估

先看最终 SQL、索引和执行计划。

ORM 解决的是开发效率问题,不会自动解决性能、权限、事务和数据建模问题。

重要警示

不要在服务启动时执行 AutoMigrate。

不要这样做
gormDB.AutoMigrate(
	&User{},
	&Album{},
	&Photo{},
)

AutoMigrate 可以用于个人原型或临时实验,但不应该作为正式上线系统的数据库变更方案。

服务每次启动都修改数据库结构,安全吗?

多个实例同时启动会发生什么?

表结构修改是否经过审核?

生产数据库变更失败后如何回滚?

代码回滚是否意味着数据库也能自动回滚?

数据库变更是否有完整记录?

推荐方式:显式 SQL + 审核流程

database migrations
database/
├── migrations/
│   ├── 001_create_users.sql
│   ├── 002_create_albums.sql
│   ├── 003_create_photos.sql
│   └── 004_add_album_index.sql
└── rollback/
    ├── 004_remove_album_index.sql
    └── ...

编写 SQL → 代码评审 → 测试验证 → 评估影响与回滚方案 → 审核执行 → 记录结果

数据库结构变化必须是可审计的发布行为。

推荐代码结构

不要把所有数据库逻辑写在 Gin Handler 中。

请求 → Handler → Service → Repository → GORM / MySQL

repository.go
type AlbumRepository struct {
	db *gorm.DB
}

func (r *AlbumRepository) List(limit int) ([]Album, error) {
	var albums []Album

	result := r.db.
		Select("id", "user_id", "name", "visibility", "created_at").
		Order("created_at DESC").
		Limit(limit).
		Find(&albums)

	return albums, result.Error
}

本章最终结论

  1. Gin Handler 可以通过数据库连接池执行查询并返回 JSON。
  2. sql.DB 是连接池,应在应用启动时创建并复用。
  3. 原生 SQL 需要正确处理参数、扫描、关闭、错误和超时。
  4. 普通 CRUD 场景优先使用 GORM,提高开发效率。
  5. 使用 GORM 后仍然必须理解 SQL、索引、事务和查询性能。
  6. 正式项目禁止依赖 AutoMigrate 自动修改生产数据库结构。
  7. 数据库变更必须通过显式 SQL、代码评审、测试验证和审核流程。
接口已经能查询数据库了,但用户可以修改 URL 中的相册 ID。
后端如何保证他只能访问自己的数据?