1. 为什么用 Go 写 CRUD
CRUD(Create、Read、Update、Delete)几乎是所有后端系统的骨架。无论是电商的商品管理、SaaS 的租户配置,还是内部运维平台的资产台账,本质上都是对某类实体做增删改查。
Go 语言在这类场景里优势明显:
- 编译型 + 静态类型:接口字段、数据库模型在编译期就能发现大部分错误,重构时改一处编译器会帮你找出所有调用点。
- 标准库够用 :
net/http、encoding/json、database/sql组合起来就能跑一个完整的 REST 服务,不必一上来就引入重型框架。 - 并发模型简单 :
goroutine+context让超时控制、批量处理写起来很自然。 - 部署成本低:单二进制、无运行时依赖,容器镜像可以做到几十 MB。
本文以一个**「设备资产管理系统」**为例,把 CRUD 的每个环节落到真实业务语义上,而不是只写一个玩具级的 User 结构体。
2. 业务场景与数据模型
假设我们维护一个公司内部的设备台账:每台设备有编号、名称、类型、所属部门、状态(在用/维修/报废)和入库时间。运维同学需要:
- 新设备入库时登记(Create)
- 按部门、状态筛选查看(Read)
- 设备调拨或状态变更时修改(Update)
- 设备报废后归档删除(Delete)
先定义模型:
go
package model
import "time"
// DeviceStatus 设备状态
type DeviceStatus string
const (
StatusInUse DeviceStatus = "in_use" // 在用
StatusRepair DeviceStatus = "repair" // 维修中
StatusRetired DeviceStatus = "retired" // 已报废
)
// Device 设备实体
type Device struct {
ID int64 `json:"id"`
Code string `json:"code"` // 设备编号,业务唯一
Name string `json:"name"`
Category string `json:"category"` // 类型:笔记本/服务器/打印机
Dept string `json:"dept"` // 所属部门
Status DeviceStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
这里有两个容易被忽略的细节:
Code是业务唯一键,和自增ID不是一回事。真实系统里经常需要按业务编号查询,所以数据库要给它建唯一索引。Status用自定义字符串类型而不是int,好处是日志和接口返回可读,坏处是需要在入库前做校验。
3. 数据库层:用 database/sql 写可测试的仓储
3.1 建表语句
sql
CREATE TABLE devices (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL,
dept VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'in_use',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_devices_dept_status ON devices (dept, status);
(dept, status) 的联合索引对应最常见的筛选条件,避免全表扫描。
3.2 仓储接口
先定义接口,再写实现,这样单元测试可以换成内存实现,不必依赖真实数据库:
go
package repo
import (
"context"
"example.com/device/model"
)
type DeviceRepo interface {
Create(ctx context.Context, d *model.Device) error
GetByID(ctx context.Context, id int64) (*model.Device, error)
List(ctx context.Context, dept string, status model.DeviceStatus, limit, offset int) ([]*model.Device, error)
Update(ctx context.Context, d *model.Device) error
Delete(ctx context.Context, id int64) error
}
3.3 PostgreSQL 实现
go
package repo
import (
"context"
"database/sql"
"errors"
"fmt"
"example.com/device/model"
)
var ErrNotFound = errors.New("device not found")
type pgDeviceRepo struct {
db *sql.DB
}
func NewPGDeviceRepo(db *sql.DB) DeviceRepo {
return &pgDeviceRepo{db: db}
}
func (r *pgDeviceRepo) Create(ctx context.Context, d *model.Device) error {
const q = `INSERT INTO devices (code, name, category, dept, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`
err := r.db.QueryRowContext(ctx, q, d.Code, d.Name, d.Category, d.Dept, d.Status).
Scan(&d.ID, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return fmt.Errorf("create device: %w", err)
}
return nil
}
func (r *pgDeviceRepo) GetByID(ctx context.Context, id int64) (*model.Device, error) {
const q = `SELECT id, code, name, category, dept, status, created_at, updated_at
FROM devices WHERE id = $1`
var d model.Device
err := r.db.QueryRowContext(ctx, q, id).Scan(
&d.ID, &d.Code, &d.Name, &d.Category, &d.Dept, &d.Status, &d.CreatedAt, &d.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get device: %w", err)
}
return &d, nil
}
func (r *pgDeviceRepo) List(ctx context.Context, dept string, status model.DeviceStatus, limit, offset int) ([]*model.Device, error) {
const q = `SELECT id, code, name, category, dept, status, created_at, updated_at
FROM devices
WHERE ($1 = '' OR dept = $1)
AND ($2 = '' OR status = $2)
ORDER BY id DESC
LIMIT $3 OFFSET $4`
rows, err := r.db.QueryContext(ctx, q, dept, status, limit, offset)
if err != nil {
return nil, fmt.Errorf("list devices: %w", err)
}
defer rows.Close()
var out []*model.Device
for rows.Next() {
var d model.Device
if err := rows.Scan(&d.ID, &d.Code, &d.Name, &d.Category, &d.Dept, &d.Status, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan device: %w", err)
}
out = append(out, &d)
}
return out, rows.Err()
}
func (r *pgDeviceRepo) Update(ctx context.Context, d *model.Device) error {
const q = `UPDATE devices
SET name = $1, category = $2, dept = $3, status = $4, updated_at = now()
WHERE id = $5`
res, err := r.db.ExecContext(ctx, q, d.Name, d.Category, d.Dept, d.Status, d.ID)
if err != nil {
return fmt.Errorf("update device: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (r *pgDeviceRepo) Delete(ctx context.Context, id int64) error {
const q = `DELETE FROM devices WHERE id = $1`
res, err := r.db.ExecContext(ctx, q, id)
if err != nil {
return fmt.Errorf("delete device: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
几个实践要点:
- 全部方法带
ctx:HTTP 请求超时或客户端断开时,数据库查询能被及时取消,避免连接被长时间占用。 Update不更新code:业务编号一旦生成就不允许改,这是很多系统的硬性约束。RowsAffected判断存在性:比先查再改少一次往返,也避免并发下的竞态。- 错误用
%w包装 :上层可以用errors.Is(err, repo.ErrNotFound)判断,而不必解析字符串。
4. HTTP 层:把 CRUD 映射成 REST 接口
4.1 路由与处理器
go
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"example.com/device/model"
"example.com/device/repo"
)
type DeviceHandler struct {
repo repo.DeviceRepo
}
func NewDeviceHandler(r repo.DeviceRepo) *DeviceHandler {
return &DeviceHandler{repo: r}
}
func (h *DeviceHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/devices", h.create)
mux.HandleFunc("GET /api/devices", h.list)
mux.HandleFunc("GET /api/devices/{id}", h.get)
mux.HandleFunc("PUT /api/devices/{id}", h.update)
mux.HandleFunc("DELETE /api/devices/{id}", h.delete)
}
Go 1.22 起 net/http 的 ServeMux 已支持方法匹配和路径参数,中小项目可以省掉第三方路由库。
4.2 Create:入库登记
go
type createReq struct {
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
Dept string `json:"dept"`
}
func (h *DeviceHandler) create(w http.ResponseWriter, r *http.Request) {
var req createReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.Code == "" || req.Name == "" || req.Dept == "" {
writeErr(w, http.StatusBadRequest, "code/name/dept required")
return
}
d := &model.Device{
Code: req.Code,
Name: req.Name,
Category: req.Category,
Dept: req.Dept,
Status: model.StatusInUse,
}
if err := h.repo.Create(r.Context(), d); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, d)
}
真实业务里,code 重复会触发数据库唯一约束错误。生产环境建议把这类错误映射成 409 Conflict,而不是笼统的 500。
4.3 Read:列表与详情
go
func (h *DeviceHandler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
dept := q.Get("dept")
status := model.DeviceStatus(q.Get("status"))
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 || limit > 100 {
limit = 20
}
offset, _ := strconv.Atoi(q.Get("offset"))
if offset < 0 {
offset = 0
}
items, err := h.repo.List(r.Context(), dept, status, limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset})
}
func (h *DeviceHandler) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
d, err := h.repo.GetByID(r.Context(), id)
if errors.Is(err, repo.ErrNotFound) {
writeErr(w, http.StatusNotFound, "device not found")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, d)
}
limit 强制上限是必须的:没有上限的分页接口,一个 limit=1000000 就能把服务打挂。
4.4 Update 与 Delete
go
type updateReq struct {
Name string `json:"name"`
Category string `json:"category"`
Dept string `json:"dept"`
Status model.DeviceStatus `json:"status"`
}
func (h *DeviceHandler) update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
var req updateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
d := &model.Device{ID: id, Name: req.Name, Category: req.Category, Dept: req.Dept, Status: req.Status}
if err := h.repo.Update(r.Context(), d); err != nil {
if errors.Is(err, repo.ErrNotFound) {
writeErr(w, http.StatusNotFound, "device not found")
return
}
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, d)
}
func (h *DeviceHandler) delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.repo.Delete(r.Context(), id); err != nil {
if errors.Is(err, repo.ErrNotFound) {
writeErr(w, http.StatusNotFound, "device not found")
return
}
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
4.5 统一响应工具
go
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
5. 组装与启动
go
package main
import (
"database/sql"
"log"
"net/http"
"time"
_ "github.com/lib/pq"
"example.com/device/handler"
"example.com/device/repo"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/assets?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
mux := http.NewServeMux()
handler.NewDeviceHandler(repo.NewPGDeviceRepo(db)).Register(mux)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Println("listening on :8080")
log.Fatal(srv.ListenAndServe())
}
连接池参数不是随便填的:MaxOpenConns 要小于数据库的 max_connections 除以实例数,否则扩容时容易把数据库连接打满。
6. 请求流转示意
#mermaid-svg-heKmD5mpbkozYeWE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-heKmD5mpbkozYeWE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-heKmD5mpbkozYeWE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-heKmD5mpbkozYeWE .error-icon{fill:#552222;}#mermaid-svg-heKmD5mpbkozYeWE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-heKmD5mpbkozYeWE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-heKmD5mpbkozYeWE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-heKmD5mpbkozYeWE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-heKmD5mpbkozYeWE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-heKmD5mpbkozYeWE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-heKmD5mpbkozYeWE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-heKmD5mpbkozYeWE .marker.cross{stroke:#333333;}#mermaid-svg-heKmD5mpbkozYeWE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-heKmD5mpbkozYeWE p{margin:0;}#mermaid-svg-heKmD5mpbkozYeWE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster-label text{fill:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster-label span{color:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster-label span p{background-color:transparent;}#mermaid-svg-heKmD5mpbkozYeWE .label text,#mermaid-svg-heKmD5mpbkozYeWE span{fill:#333;color:#333;}#mermaid-svg-heKmD5mpbkozYeWE .node rect,#mermaid-svg-heKmD5mpbkozYeWE .node circle,#mermaid-svg-heKmD5mpbkozYeWE .node ellipse,#mermaid-svg-heKmD5mpbkozYeWE .node polygon,#mermaid-svg-heKmD5mpbkozYeWE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .rough-node .label text,#mermaid-svg-heKmD5mpbkozYeWE .node .label text,#mermaid-svg-heKmD5mpbkozYeWE .image-shape .label,#mermaid-svg-heKmD5mpbkozYeWE .icon-shape .label{text-anchor:middle;}#mermaid-svg-heKmD5mpbkozYeWE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .rough-node .label,#mermaid-svg-heKmD5mpbkozYeWE .node .label,#mermaid-svg-heKmD5mpbkozYeWE .image-shape .label,#mermaid-svg-heKmD5mpbkozYeWE .icon-shape .label{text-align:center;}#mermaid-svg-heKmD5mpbkozYeWE .node.clickable{cursor:pointer;}#mermaid-svg-heKmD5mpbkozYeWE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-heKmD5mpbkozYeWE .arrowheadPath{fill:#333333;}#mermaid-svg-heKmD5mpbkozYeWE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-heKmD5mpbkozYeWE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-heKmD5mpbkozYeWE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-heKmD5mpbkozYeWE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-heKmD5mpbkozYeWE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-heKmD5mpbkozYeWE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-heKmD5mpbkozYeWE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .cluster text{fill:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster span{color:#333;}#mermaid-svg-heKmD5mpbkozYeWE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-heKmD5mpbkozYeWE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-heKmD5mpbkozYeWE rect.text{fill:none;stroke-width:0;}#mermaid-svg-heKmD5mpbkozYeWE .icon-shape,#mermaid-svg-heKmD5mpbkozYeWE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-heKmD5mpbkozYeWE .icon-shape p,#mermaid-svg-heKmD5mpbkozYeWE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-heKmD5mpbkozYeWE .icon-shape .label rect,#mermaid-svg-heKmD5mpbkozYeWE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-heKmD5mpbkozYeWE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-heKmD5mpbkozYeWE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-heKmD5mpbkozYeWE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
ErrNoRows
其他错误
成功
客户端 HTTP 请求
ServeMux 路由匹配
Handler 解析参数与校验
参数合法?
返回 400
Repo 执行 SQL
数据库返回
返回 404
返回 500
序列化 JSON 返回
7. 真实业务中的几个坑
7.1 删除用软删除还是硬删除
设备报废后直接 DELETE 会丢失历史,审计时查不到。更常见的做法是加 deleted_at 字段做软删除,查询时统一带 WHERE deleted_at IS NULL。代价是所有查询都要记得加条件,建议在仓储层封装,不要散落在各处。
7.2 并发更新覆盖
两个人同时编辑同一台设备,后提交的会覆盖先提交的。解决办法是加乐观锁:
sql
ALTER TABLE devices ADD COLUMN version INT NOT NULL DEFAULT 1;
更新时带上版本号:
sql
UPDATE devices SET name = $1, version = version + 1
WHERE id = $2 AND version = $3;
RowsAffected == 0 说明版本已过期,返回 409 Conflict 让前端刷新后重试。
7.3 批量导入
新办公室搬迁时往往一次导入几百台设备。逐条 INSERT 会很慢,可以用单条多值插入或 COPY:
go
func (r *pgDeviceRepo) BatchCreate(ctx context.Context, ds []*model.Device) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `INSERT INTO devices (code, name, category, dept, status) VALUES ($1,$2,$3,$4,$5)`)
if err != nil {
return err
}
defer stmt.Close()
for _, d := range ds {
if _, err := stmt.ExecContext(ctx, d.Code, d.Name, d.Category, d.Dept, d.Status); err != nil {
return err
}
}
return tx.Commit()
}
放在事务里,任何一条失败整体回滚,避免导入一半留下脏数据。
7.4 上下文超时
Handler 里最好给数据库操作单独设超时,而不是完全依赖 HTTP 的超时:
go
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
d, err := h.repo.GetByID(ctx, id)
这样即使 HTTP 层超时设置得比较宽松,慢查询也不会拖住连接池。
8. 小结
把 CRUD 写出来不难,难的是让它经得起真实业务的考验。本文这套结构可以总结为几条经验:
- 接口先行:仓储层定义接口,实现可替换,测试不依赖数据库。
- 错误要可判断 :用哨兵错误 +
%w包装,让上层能区分「不存在」和「真出错」。 - 参数必须校验:分页上限、必填字段、ID 格式,缺一个就是线上事故的入口。
- 考虑并发与历史:乐观锁、软删除这些不是过度设计,而是业务跑一段时间后必然要补的。
- 连接池与超时要显式配置:默认值在低流量下看不出问题,压测时才会暴露。
下一步可以在此基础上加中间件(日志、鉴权、限流),或者把仓储层换成 sqlc、GORM 等工具减少样板代码------但无论用什么工具,上面这些业务层面的判断都不会变。