Reference · เก็บไว้เปิดตอนเขียนเทส

Go Day 7 — Cheat Sheet

Mockery · Testcontainers · Goose · Redis · ทุก output ในหน้านี้คัดมาจากการรันจริงบน Go 1.26.5 darwin/arm64 · mockery v3.7.3 · testcontainers-go v0.44.0 · goose v3.27.3 · go-redis v9.22.0 · Redis 7.4.10 · postgres:16-alpine · พิมพ์ลงกระดาษได้

สารบัญ

  1. Mockery — ติดตั้ง & คำสั่ง
  2. Mockery — .mockery.yml
  3. Mockery — เขียนเทสด้วย EXPECT()
  4. Testcontainers — ติดตั้ง & environment
  5. Testcontainers — โครงที่ก็อปไปใช้ได้
  6. Testcontainers — sync.Once + testify suite
  7. Goose — คำสั่ง & .env
  8. Goose — โครงไฟล์ migration
  9. Redis — คำสั่งใน redis-cli
  10. Redis — cache-aside ใน Go
  11. Redis — KEYS vs SCAN
  12. อันไหนใช้เมื่อไหร่ — mock vs container จริง
  13. ตารางแปล error ที่เจอบ่อย
  14. ตารางกับดักรวม

1 · Mockery — ติดตั้ง & คำสั่ง

go install github.com/vektra/mockery/v3@latest
// → binary ชื่อ `mockery` เฉย ๆ ที่ $GOPATH/bin/mockery (ไม่มี v3 ต่อท้าย)

mockery version      // v3.7.3   ← subcommand ไม่ใช่ flag
mockery --version    // Error: unknown flag: --version
อยากทำอะไรคำสั่ง v3หมายเหตุ
ดูเวอร์ชันmockery versionไม่มี --version
สร้าง configmockery init <module_path>ได้ .mockery.yml · ทับของเดิมเงียบ ๆ
generate mockmockery เปล่า ๆไม่มี subcommand generate
ดู config ที่ใช้จริงmockery showconfigรวมค่า default ที่ไม่ได้เขียนไว้
ระบุ config เองmockery --config <file>ใช้ชื่อไฟล์อะไรก็ได้
ย้าย config v2 → v3mockery migrate—

v3 มี subcommand แค่ 5 ตัว: completion · help · init · migrate · showconfig · version — ไม่มี generate เพราะการ generate คือ root command

ขั้นตอนตั้งแต่ศูนย์

cd app                                   # โฟลเดอร์ที่มี go.mod
mockery init example.com/shop            # ใส่ module path จาก go.mod ไม่ใช่ชื่อ package
# แก้ .mockery.yml เติม recursive: true  ← ขั้นที่คนลืมกันมากที่สุด
mockery                                  # generate
go mod tidy                              # testify ไม่ถูกเติมให้เอง ต้องรันเอง

✅ ยืนยันว่า generate สำเร็จ = ต้องเห็นบรรทัดนี้ใน log
INF adding interface to collection ... interface=UserRepository package-path=example.com/shop/user
⚠️ exit code เชื่อไม่ได้ — package ผิดก็ยัง exit 0

2 · Mockery — .mockery.yml

ไฟล์ที่ mockery init สร้างจริงมี 28 บรรทัด · นี่คือฟิลด์ที่ต้องรู้จัก:

all: false                          # ระดับบนสุดเป็น false (ไม่ใช่ true)
dir: '{{.InterfaceDir}}'            # วางไฟล์ mock ข้าง ๆ interface
filename: mocks_test.go               # ← ใช้ข้าม package ไม่ได้ ดูข้อ 8 ในตารางกับดัก
force-file-write: true               # อนุญาตเขียนทับ · false = FTL "outfile exists"
formatter: goimports
generate: true
include-auto-generated: false
log-level: info
structname: '{{.Mock}}{{.InterfaceName}}'   # → MockUserRepository
pkgname: '{{.SrcPackageName}}'
recursive: false                    # ← ตัวที่ทำให้ config เริ่มต้นใช้ไม่ได้
require-template-schema-exists: true
template: testify                     # ได้ EXPECT() แบบ type-safe
template-schema: '{{.Template}}.schema.json'
packages:
  example.com/shop:
    config:
      all: true
      recursive: true                # ← เติมบรรทัดนี้เอง ไม่งั้นไม่เจอ sub-package
อยากได้ตั้งค่ายังไง
เห็น interface ใน sub-packagerecursive: true ใต้ packages.<module>.config
(ทางเลือก) ชี้ package ตรง ๆเปลี่ยน key เป็น example.com/shop/user:
ใช้ mock จาก package อื่นfilename: mocks.go + ลบ mocks_test.go เดิมทิ้ง
ใช้ mock เฉพาะใน package เดียวกันปล่อย filename: mocks_test.go ตาม default

ชื่อไฟล์ที่ mockery หาเจอมีแค่ .mockery.yml และ .mockery.yaml — ต้องมีจุดนำหน้า · mockery.yml เฉย ๆ = file not found
มันไล่หาขึ้นไปตาม parent directory ด้วย → รันจากใน sub-package ก็เจอ config ที่ root

3 · Mockery — เขียนเทสด้วย EXPECT()

func TestDisplayName_OK(t *testing.T) {
    repo := NewMockUserRepository(t)      // ← t.Cleanup + AssertExpectations ให้เอง
    repo.EXPECT().GetByID(7).Return(&User{ID: 7, Name: "Somchai"}, nil)

    svc := NewService(repo)
    got, err := svc.DisplayName(7)

    assert.NoError(t, err)
    assert.Equal(t, "Mr. Somchai", got)
}

ไม่ต้องเขียน defer repo.AssertExpectations(t) แล้ว — constructor ลงทะเบียน t.Cleanup(func(){ mock.AssertExpectations(t) }) ไว้ให้ (นี่คือความต่างจาก v2)

อยากได้เขียนแบบนี้
ค่า argument ตรงเป๊ะrepo.EXPECT().GetByID(7).Return(u, nil)
ไม่สนใจ argumentrepo.EXPECT().Save(mock.Anything).Return(nil)
ตรวจ argument แบบมีเงื่อนไขmock.MatchedBy(func(u *User) bool { ... })
คืน error.Return(nil, ErrNotFound)
คำนวณค่า return ตอนถูกเรียก.RunAndReturn(func(id int) (*User, error) { ... })
จำกัดจำนวนครั้ง.Once() · .Times(n) — ไม่ใส่ = กี่ครั้งก็ได้
ยืนยันว่าไม่ถูกเรียกrepo.AssertNotCalled(t, "Save", mock.Anything)
สไตล์เดิมจาก v2repo.On("GetByID", 1).Return(u, nil) — ยังใช้ได้ ปนกันได้

EXPECT() เป็นแค่ syntactic sugar ที่ห่อ mock.On("ชื่อ method", args...) ไว้ · .Return() type-safe แต่ argument ยังรับ any เพื่อให้ส่ง mock.Anything ได้

4 · Testcontainers — ติดตั้ง & environment

go mod init tcdemo
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres   # ← คนละ module ต้อง get แยก
go get gorm.io/gorm gorm.io/driver/postgres
go get github.com/stretchr/testify

ลืมบรรทัดที่สอง → no required module provides package .../modules/postgres ตั้งแต่ compile

ถ้าไม่ได้ใช้ Docker Desktop (colima / Rancher / Podman บน macOS)

export DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock

testcontainers-go v0.44.0 ไม่อ่าน docker context ของ Docker CLI — docker ps ใช้ได้ ไม่ได้แปลว่าเทสจะรันได้
ตรวจจาก log 2 บรรทัดนี้: Resolved Docker Host: (path ฝั่ง mac) และ Resolved Docker Socket Path: (path ฝั่ง VM) — สองค่านี้ต่างกันได้โดยตั้งใจ

5 · Testcontainers — โครงที่ก็อปไปใช้ได้

func StartPostgres(ctx context.Context) (*tcpostgres.PostgresContainer, time.Duration, error) {
    start := time.Now()
    ctr, err := tcpostgres.Run(ctx,
        "postgres:16-alpine",
        tcpostgres.WithDatabase("testdb"),
        tcpostgres.WithUsername("testuser"),
        tcpostgres.WithPassword("testpass"),
        tcpostgres.BasicWaitStrategies(),      // ← ห้ามลืม
    )
    return ctr, time.Since(start), err
}

func OpenGorm(ctx context.Context, ctr *tcpostgres.PostgresContainer) (*gorm.DB, string, error) {
    dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")   // ← port สุ่มอยู่ในนี้
    if err != nil { return nil, "", err }
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent),
    })
    if err != nil { return nil, dsn, err }
    if err := db.AutoMigrate(&model.User{}); err != nil { return nil, dsn, err }
    return db, dsn, nil
}

รูปแบบ cleanup ที่ถูกต้อง

❌ leak ได้ 2 ทาง

ctr, err := tcpostgres.Run(ctx, ...)
if err != nil {
    t.Fatal(err)     // ออกก่อนถึง defer
}
defer ctr.Terminate(ctx)  // panic ถ้า ctr = nil

✅ nil-safe + defer มาก่อน

ctr, err := tcpostgres.Run(ctx, ...)
defer func() {
    _ = testcontainers.TerminateContainer(ctr)
}()
if err != nil {
    t.Fatal(err)
}

Run คืนค่าไม่เหมือนกันสองกรณี: image tag ผิด → ctr = nil · wait strategy timeout → ctr ≠ nil พร้อม error (container start ไปแล้วจริง ๆ)
testcontainers.TerminateContainer(ctr) จัดการได้ทั้งคู่ — เรียกกับ typed nil ก็คืน <nil> ไม่ panic

BasicWaitStrategies() คืออะไร

wait.ForLog("database system is ready to accept connections").WithOccurrence(2)
wait.ForListeningPort("5432/tcp")

ทำไมต้อง 2 ครั้ง — postgres restart ตัวเองหลัง initdb · ทำไมต้องรอ port — macOS/Windows มี docker port-proxy แยก ถ้าไม่รอเทสจะ flaky

6 · Testcontainers — sync.Once + testify suite

var (
    sharedOnce sync.Once
    sharedCtr  *tcpostgres.PostgresContainer
    sharedDB   *gorm.DB
    sharedErr  error
    SharedBoot time.Duration
)

func SharedDB(t *testing.T) (*gorm.DB, string) {
    t.Helper()
    sharedOnce.Do(func() {
        ctx := context.Background()
        var d time.Duration
        sharedCtr, d, sharedErr = StartPostgres(ctx)
        SharedBoot = d
        if sharedErr != nil { return }
        sharedDB, sharedDSN, sharedErr = OpenGorm(ctx, sharedCtr)
    })
    if sharedErr != nil { t.Fatalf("shared container failed: %v", sharedErr) }
    return sharedDB, sharedDSN
}

// TestMain ทำแค่นี้ — ห้าม boot container ก่อน m.Run()
func TestMain(m *testing.M) {
    code := m.Run()
    testutil.TerminateShared()
    os.Exit(code)
}
4 เทสเดียวกันเป๊ะเวลารวมต้นทุนต่อเทสที่เพิ่ม
เปิด container ใหม่ทุกเทส4.264s~760 ms
sync.Once share ตัวเดียว1.727s~2 ms

เร็วขึ้น 2.47 เท่าที่ 4 เทส · ต้นทุนต่อเทสต่างกัน ~380 เท่า · boot ตัวแรกของ process ~1.0–1.3 วิ (รวมเวลา start Ryuk) ตัวถัดไป ~0.75–0.80 วิ

-short ต้องไม่แตะ Docker

func (s *UserRepoSuite) SetupSuite() {
    if testing.Short() { s.T().Skip("skipping integration suite in -short mode") }
    s.db, s.dsn = testutil.SharedDB(s.T())
    s.repo = repository.NewUserRepository(s.db)
}

func (s *UserRepoSuite) SetupTest() {          // ก่อนทุกเทส: ล้างตาราง ไม่เปิด container ใหม่
    testutil.TruncateUsers(s.T(), s.db)
}

func TestUserRepoSuite(t *testing.T) { suite.Run(t, new(UserRepoSuite)) }
เขียน TestMain แบบไหนgo test -shortboot time
boot container ก่อน m.Run()1.673s · ยังต้องมี Docker1.047s (เสียเปล่า)
lazy sync.Once0.353s · ไม่แตะ Docker0s

ลำดับ hook ของ testify suite

SetupSuite
  SetupTest → BeforeTest(suite, test) → body → AfterTest(suite, test) → TearDownTest
  ... วนซ้ำทุกเทส ...
TearDownSuite

⚠️ suite รัน method เรียงตามตัวอักษร ไม่ใช่ลำดับที่ประกาศ — ประกาศ TestZebra ก่อน TestApple แต่ TestApple รันก่อน
→ ห้ามเขียนเทสที่พึ่งพาลำดับ

nil slice — จำเป็นเฉพาะบางเส้นทาง

เขียนแบบไหน · 0 rowsisNilJSON
GORM Find เข้า var []Tfalse[]
GORM Find เข้า make([]T,0)false[]
GORM Raw().Scan()truenull
database/sql + rows.Next()truenull

GORM Find reflect-allocate slice ให้เอง → ปัญหา null ไม่เกิด · เกิดจริงกับ Raw().Scan() และ repository ที่เขียน rows.Next() เอง — แก้ด้วย make([]T, 0)

7 · Goose — คำสั่ง & .env

go install github.com/pressly/goose/v3/cmd/goose@latest
goose --version      # goose version: v3.27.3

วาง .env ที่ราก repo — goose อ่านให้เอง (flag -env default คือ .env):

GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:secret@localhost:55433/shopdb?sslmode=disable
GOOSE_MIGRATION_DIR=./migrations

⚠️ -dir default คือ "." ไม่ใช่ ./migrations — ไม่ตั้ง GOOSE_MIGRATION_DIR ไฟล์จะไปโผล่ที่ราก repo ทั้งที่มีโฟลเดอร์ migrations/ อยู่

คำสั่งทำอะไรoutput ตัวอย่างจริง
goose create <name> sqlสร้างไฟล์ SQLCreated new file: migrations/20260818125033_create_products_table.sql
goose -s create <name> sqlเลขเรียง 00001 แทน timestampCreated new file: migrations/00001_init.sql
goose create <name> gomigration แบบ Go (AddMigrationContext)—
goose upรันที่ค้างทั้งหมดgoose: successfully migrated database to version: 20260818125035
goose up (ซ้ำ)ไม่ทำอะไรgoose: no migrations to run. current version: 20260818125201
goose up-by-oneขึ้นทีละไฟล์OK 20260818125033_create_products_table.sql (3.37ms)
goose up-to <ver>ขึ้นถึง version ที่ระบุ—
goose downย้อน 1 ขั้นOK 20260818125035_add_index_products_name.sql (11.81ms)
goose redodown แล้ว up ไฟล์เดิมพิมพ์ชื่อไฟล์เดิม 2 บรรทัด ไม่บอกว่าอันไหน down/up
goose resetย้อนทั้งหมด (ใหม่ → เก่า)—
goose statusดูว่าอันไหน Pendingเขียน DB ด้วย — สร้าง goose_db_version ทันที
goose versionเลข version ปัจจุบันgoose: version 20260818125035 · ยังไม่ up = version 0
goose validateตรวจ syntax/annotationไม่แตะ DB → เอาไปใส่ CI ได้
goose -allow-missing upยอมรัน migration ที่ timestamp เก่ากว่า⚠️ ทำ goose version เพี้ยน

ตาราง goose_db_version

 Column     | Type                        | Nullable | Default
------------+-----------------------------+----------+----------------------------------
 id         | integer                     | not null | generated by default as identity
 version_id | bigint                      | not null |
 is_applied | boolean                     | not null |
 tstamp     | timestamp without time zone | not null | now()

goose เก็บแค่ เลข version + เวลา — ไม่มี checksum ของไฟล์ (ต่างจาก Flyway/Liquibase) นี่คือรากของกับดัก "แก้ไฟล์ย้อนหลัง" และ "dev/prod แตกกัน"
goose down = DELETE แถวทิ้ง ไม่ใช่ set is_applied = false (ทุกแถวเป็น t เสมอ · id จึงไม่ต่อเนื่องหลัง down/up หลายรอบ)

🕐 output มี สองโซนเวลาปนกัน — prefix ของ log เป็นเวลาเครื่อง (19:51) ส่วนชื่อไฟล์และคอลัมน์ Applied At เป็น UTC (12:51)

8 · Goose — โครงไฟล์ migration

template ที่ goose create ... sql สร้างให้มี แค่ 4 บรรทัด (ไม่มี StatementBegin/End มาให้ — ต้องพิมพ์เอง):

-- +goose Up
SELECT 'up SQL query';

-- +goose Down
SELECT 'down SQL query';

DDL ปกติ

-- +goose Up
CREATE TABLE products (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    price      NUMERIC(10,2) NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE products;

function / trigger — ต้องครอบ StatementBegin/End

-- +goose Up
ALTER TABLE products ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now();

-- +goose StatementBegin
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd

CREATE TRIGGER trg_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

-- +goose Down
DROP TRIGGER trg_products_updated_at ON products;
DROP FUNCTION set_updated_at();
ALTER TABLE products DROP COLUMN updated_at;

goose แบ่ง statement ด้วย ; แบบง่าย ๆ ไม่รู้จัก dollar-quoted string — ครอบเฉพาะ block ที่มี ; ข้างในก็พอ statement อื่นในไฟล์เดียวกันปล่อยไว้ได้

CREATE INDEX CONCURRENTLY — ต้องปิด transaction

-- +goose NO TRANSACTION
-- +goose Up
CREATE INDEX CONCURRENTLY idx_items_sku ON items (sku);

-- +goose Down
DROP INDEX CONCURRENTLY idx_items_sku;

goose ห่อทุกไฟล์ด้วย transaction โดย default (ไฟล์ที่พังกลางทางจึง rollback ทั้งไฟล์ ไม่มี state ค้าง) แต่ Postgres ห้าม CONCURRENTLY อยู่ใน transaction block
ใส่ NO TRANSACTION แล้ว = ไฟล์นั้นไม่มี rollback อัตโนมัติ → ใส่ statement เดียวต่อไฟล์

seed master data = migration ตัวหนึ่ง

-- +goose Up
INSERT INTO categories (code, name) VALUES
    ('ELEC', 'Electronics'),
    ('BOOK', 'Books'),
    ('FOOD', 'Food & Drink');

-- +goose Down
DELETE FROM categories WHERE code IN ('ELEC','BOOK','FOOD');

รัน goose up ซ้ำ 3 รอบ ข้อมูลยังเป็น 3 แถว — ตัวที่กันซ้ำคือ goose_db_version ไม่ใช่ SQL (INSERT ธรรมดาไม่มี ON CONFLICT ก็ไม่ซ้ำ เพราะ goose ไม่รันไฟล์นั้นอีกเลย)

goose vs GORM AutoMigrate

งานAutoMigrategoose
สร้างตาราง / เพิ่มคอลัมน์✅ ได้✅ ได้
เปลี่ยน type คอลัมน์เดิม✅ ได้ (สั่ง ALTER ... USING ให้เอง)✅ เขียนเอง คุมได้
rename คอลัมน์❌ สร้างคอลัมน์ใหม่ ข้อมูลไม่ตามมา✅ RENAME COLUMN ข้อมูลอยู่ครบ
ลบคอลัมน์❌ ไม่ลบให้ (โดยตั้งใจ)✅ DROP COLUMN
เพิ่ม NOT NULL บนคอลัมน์เดิม❌ บอก OK แต่ไม่ทำ✅ SET NOT NULL (มี NULL ค้าง → error ให้เห็น)
ตาราง version / rollback❌ ไม่มีเลย✅ goose_db_version
รีวิว SQL ก่อน deploy❌ SQL เกิดตอน runtime✅ เป็นไฟล์ใน git

⚠️ rollback = ย้อน schema ไม่ใช่ย้อนข้อมูล · Down ที่ DROP COLUMN แล้ว up ใหม่ → ค่าเดิม 42, 7 กลายเป็น 0, 0 ถาวร

9 · Redis — คำสั่งใน redis-cli

docker run -d --name day7-redis -p 55379:6379 redis:7-alpine
docker exec day7-redis redis-cli INFO server   # redis_version:7.4.10
คำสั่งคืนอะไรตอน key ไม่มี
SET k vOK—
SET k v EX 3OK + ตั้ง TTL ในคำสั่งเดียว—
GET kค่าว่าง / (nil)
EXISTS k10
TYPE kstring · list · …none
DEL kจำนวน key ที่ลบได้ (ไม่ใช่ OK)0 — ลบซ้ำไม่ error
TTL kวินาทีที่เหลือ · -1 = ไม่มี expire-2
EXPIRE k 10010 — ไม่ error
PERSIST k1 (ถอด expire → TTL กลับเป็น -1)0
DBSIZEจำนวน key ทั้ง db—

TTL สามค่าที่ต้องจำ: -2 = ไม่มี key · -1 = มี key แต่ไม่มีวันหมดอายุ · เลขบวก = วินาทีที่เหลือ
หมดอายุแล้ว key หายจริง (TTL -2 · EXISTS 0 · TYPE none) — แยกไม่ออกจาก key ที่ไม่เคยมี

⚠️ output ของ redis-cli ต่างกันเมื่อมี TTY กับตอน pipe — ในเทอร์มินัลเห็น (nil) / (integer) 1 แต่ตอน pipe ได้บรรทัดว่างกับเลขเปล่า · script ที่เช็คผลต้องเขียนตามแบบหลัง

เรื่อง persistence ที่มักเข้าใจผิด

CONFIG GET save          # 3600 1 300 100 60 10000   ← RDB เปิดอยู่ default
CONFIG GET appendonly    # no                        ← AOF ปิดอยู่ default

Redis ทำงานบน RAM แต่ persist ลงดิสก์ได้และ default เปิด RDB ไว้ — ข้อมูลรอด docker restart (SIGTERM = save ก่อนตาย) แต่ หายถ้าโดน SIGKILL (สิ่งที่เขียนหลัง save ล่าสุด)

10 · Redis — cache-aside ใน Go

go get github.com/redis/go-redis/v9   // v9.22.0

rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:55379"})
defer rdb.Close()
if err := rdb.Ping(ctx).Err(); err != nil {   // ← NewClient เป็น lazy ไม่เคย error
    log.Fatal(err)
}
func getUser(ctx context.Context, rdb *redis.Client, id int) (User, string, error) {
    key := fmt.Sprintf("user:%d", id)

    val, err := rdb.Get(ctx, key).Result()
    if err == nil {                          // HIT
        var u User
        if err := json.Unmarshal([]byte(val), &u); err != nil {
            return User{}, "", err
        }
        return u, "HIT", nil
    }
    if !errors.Is(err, redis.Nil) {           // error จริง — ต้องแยกจาก cache miss
        return User{}, "", fmt.Errorf("redis error: %w", err)
    }

    u := queryDB(id)                          // MISS
    b, err := json.Marshal(u)                  // ← อย่าใส่ _ ทิ้ง error
    if err != nil { return User{}, "", err }
    if err := rdb.Set(ctx, key, b, 60*time.Second).Err(); err != nil {
        return User{}, "", err
    }
    return u, "MISS", nil
}

❌ 500 ทุก cache miss

val, err := rdb.Get(ctx, k).Result()
if err != nil {
    return err      // redis: nil ก็เข้าตรงนี้
}

✅ แยก miss ออกจาก error

val, err := rdb.Get(ctx, k).Result()
switch {
case errors.Is(err, redis.Nil):  // miss
case err != nil:                 // error จริง
default:                        // hit
}

ใช้ errors.Is ไม่ใช่ == (บทเรียน 12) เพราะโค้ดชั้นบนอาจ %w ห่อไว้

วัดจริงเวลา
MISS (DB จำลอง sleep 200 ms)202.9 ms
HIT (หลัง connection pool warm)0.73 ms → เร็วขึ้น ~278 เท่า
HIT ครั้งแรก (pool ยังไม่ warm)1.83 ms — อย่าเอาไปคิดเป็น benchmark

ชนิดที่ Set รับได้

// ✅ string · int · float64 · bool (เก็บเป็น "1") · []byte · time.Duration (nanosecond)
// ❌ struct · *struct · map · slice  →  redis: can't marshal ...

b, err := json.Marshal(u)
rdb.Set(ctx, key, b, ttl)      // marshal เองก่อนเสมอ

cache stampede

var mu sync.Mutex

func getGuarded(ctx context.Context, rdb *redis.Client) string {
    if v, err := rdb.Get(ctx, "hot").Result(); err == nil { return v }
    mu.Lock()
    defer mu.Unlock()
    // เช็คซ้ำหลังได้ lock — คนอื่นอาจเติม cache ไปแล้ว
    if v, err := rdb.Get(ctx, "hot").Result(); err == nil { return v }
    v := queryDB()
    rdb.Set(ctx, "hot", v, 60*time.Second)
    return v
}
100 request พร้อมกันตอน cache ว่างDB ถูกยิงเวลา
ไม่มี lock100 ครั้ง269 ms
mutex + double-check1 ครั้ง282 ms

ต้นทุนแทบเป็นศูนย์ (269 vs 282 ms) — แต่ sync.Mutex (บทเรียน 24) กันได้แค่ภายในโปรเซสเดียว · หลาย instance ต้องใช้ distributed lock

11 · Redis — KEYS vs SCAN

var cursor uint64
for {
    batch, cur, err := rdb.Scan(ctx, cursor, "user:*", 1000).Result()
    if err != nil { return err }
    // ใช้ len(batch) จริง อย่ายึด COUNT เป็นขนาดตายตัว
    cursor = cur
    if cursor == 0 { break }      // ← cursor 0 เท่านั้นที่แปลว่าจบ
}
บน 1,000,000 keysKEYS *SCAN
เวลาที่ Redis ใช้ต่อคำสั่ง (SLOWLOG)146,086 µs57 µs
เวลารวมฝั่ง Go150 ms862 ms (วน 1,000 รอบ)
latency ของ client อื่น (max)274 ms2 ms (เท่า baseline)
หน่วยความจำฝั่ง client+30.5 MB ในครั้งเดียวถือทีละ ~1,000 key

⚠️ SCAN ไม่ได้ "เร็วกว่า" — มันช้ากว่า 5.7 เท่าในแง่เวลารวม
เหตุผลที่ต้องใช้คือ Redis ประมวลผลทีละคำสั่งบน thread เดียว → KEYS แช่แข็ง client ทุกคน 146 ms ส่วน SCAN ตัดงานเป็นชิ้นละ 57 µs

COUNT เป็นแค่ hint ไม่ใช่ limit — ขอ 1000 ได้กลับมา 1004 · ขอ COUNT 2 อาจได้ key 1 ตัวพร้อม cursor 3 (ยังไม่จบ)

🛠 อยากสร้าง key เยอะ ๆ ทดสอบ — DEBUG POPULATE ถูกปิดใน Redis 7 ให้ใช้ Lua แทน:
redis-cli EVAL "for i=1,100000 do redis.call('SET', KEYS[1]..i, i) end return 1" 1 "bulk:0:"

12 · อันไหนใช้เมื่อไหร่ — mock vs container จริง

คำถามเดียวที่ต้องถามกับเทสทุกตัว:

  "สิ่งที่เทสนี้จะพัง คือ logic ของเรา หรือ สัญญากับของข้างนอก?"

        logic ของเรา                        สัญญากับของข้างนอก
   (if/else · การคำนวณ · error path)   (column · ตาราง · constraint · type · migration)
              │                                        │
              ▼                                        ▼
     mock (mockery)                       container จริง (testcontainers)
        ~1.25 ms/เทส                    boot 1.03 วิ ครั้งเดียว แล้ว ~2 ms/เทส

หลักฐานว่าทำไมต้องมีทั้งคู่

ฟังก์ชันเดียวกัน SearchByCity ที่ query column city ซึ่งไม่มีในตาราง:

mock (go-sqlmock) — ไม่จับ

=== RUN   TestMockedSearchByCity_PASSES_WITH_A_BUG
    MOCK RESULT: len=1 first=Ploy  (took 166.042µs)
--- PASS  (0.00s)

postgres จริง — จับได้

=== RUN   TestSearchByCity_CaughtByRealPostgres
    VERBATIM real-postgres error:
    ERROR: column "city" does not exist (SQLSTATE 42703)
--- PASS  (1.07s)
เทสสิ่งนี้ใช้เพราะ
service เรียก repository ด้วย argument ถูกไหมmockเป็น logic ล้วน · EXPECT() เช็ค argument ให้ฟรี
โค้ดจัดการ error จาก dependency ยังไงmockสร้าง error ตามใจได้ · DB จริงบังคับให้พังยาก
เส้นทางที่ ไม่ ควรเรียก dependencymockAssertNotCalled · container พิสูจน์ไม่ได้
dependency ที่เป็น API ภายนอก / paymentmockยิงของจริงไม่ได้
SQL ใช้ได้กับ schema จริงไหมcontainermock ไม่มี parser ไม่รู้จัก schema
column / ตาราง สะกดถูกไหมcontainer42703 · 42P01 จับได้ทันที
unique index มีจริงไหมcontainerเป็น DDL · 23505 เท่านั้นที่พิสูจน์ได้
migration ของ goose รันผ่านไหมcontainerต้องมี DB จริงถึงจะรัน
type ที่ scan กลับมาตรงไหม / nil slicecontainerขึ้นกับ driver ตัวจริง

ไม่ใช่คำถาม "อันไหนดีกว่า" แต่เป็น "อันไหนตอบคำถามอะไร"
mock ตรวจว่า logic ของเราเรียกถูกไหม · container ตรวจว่า SQL ใช้ได้กับของจริงไหม
ทุกอย่างที่เป็นสัญญากับ DB — column, ตาราง, constraint, type, migration — ต้องมี integration test ไม่มีทางลัด

ราคาที่จ่ายจริง

แบบต้นทุนวิธีคุมไม่ให้แพง
mock~1.25 ms/เทส—
container ตัวแรก~1.0–1.3 วิจ่ายครั้งเดียวด้วย sync.Once
container เทสถัดไป~2 ms (พอ ๆ กับ mock)TRUNCATE ใน SetupTest แทนการเปิดใหม่
เครื่องที่ไม่มี Docker / CI เร็ว ๆ—testing.Short() + t.Skip + boot แบบ lazy

⚠️ ข้อจำกัดของ sync.Once ที่ต้องรู้ — ทุกเทสใช้ DB ตัวเดียวกัน ดังนั้น TRUNCATE ใน SetupTest จะชนกันทันทีถ้าเปิด t.Parallel()

13 · ตารางแปล error ที่เจอบ่อย

Redis / go-redis

เห็นแบบนี้แปลว่าทำอะไรต่อ
redis: nilcache miss — เหตุการณ์ปกติ ไม่ใช่ errorerrors.Is(err, redis.Nil) แล้วไปอ่าน DB
dial tcp 127.0.0.1:55999: connect: connection refusedRedis ไม่ได้เปิด / addr ผิดPing ตอน startup แล้ว fail fast
redis: can't marshal main.User (implement encoding.BinaryMarshaler)ส่ง struct ให้ Set ตรง ๆ · ไม่มีอะไรถูกเขียนเลยjson.Marshal ก่อน
WRONGTYPE Operation against a key holding the wrong kind of valueใช้คำสั่งผิด type (เช่น GET กับ list)TYPE k ดูก่อน · ไม่ใช่ redis.Nil
OOM command not allowed when used memory > 'maxmemory'.เต็มและ policy เป็น noeviction (default) · อ่าน/ลบยังได้CONFIG SET maxmemory-policy allkeys-lru
ERR DEBUG command not allowed...DEBUG ถูกปิดตั้งแต่ Redis 7ใช้ Lua EVAL สร้าง key แทน
TTL ใน Go ได้ -2ns / -1nsgo-redis เอาเลขดิบใส่ time.Duration → หน่วยเป็น ns ไม่ใช่วินาทีเทียบ ttl == -2 / ttl == -1 ตรง ๆ ก่อนคำนวณ

testcontainers-go

เห็นแบบนี้แปลว่าทำอะไรต่อ
get provider: rootless Docker not found, failed to create Docker providerหา Docker host ไม่เจอ — ไม่ได้แปลว่าปัญหาอยู่ที่ rootlessexport DOCKER_HOST=$(docker context inspect ...)
check host "<ค่าที่ตั้ง>": ...ค่าที่ลองแล้วพัง — อ่านบรรทัดแรกพอ ที่เหลือเป็น noiseแก้ค่าใน DOCKER_HOST ตามที่บอก
reaper: new reaper: ... error while creating mount source path '/Users/.../docker.sock': mkdir ...: operation not supportedRyuk mount socket ไม่ได้ — path ฝั่ง mac ไม่มีใน VMexport TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
wait until ready: "..." matched 0 times, expected 1wait strategy timeout · container start ไปแล้ว (ไม่ nil)ต้องมี defer TerminateContainer ก่อนเช็ค err
wait until ready: no wait strategy suppliedเรียก WithWaitStrategy() เปล่า ๆ · log ขึ้น Waiting for: all of: (none)ใช้ BasicWaitStrategies()
failed to resolve reference "docker.io/library/postgres:999-doesnotexist": ... not foundimage tag ผิด · คืน ctr = nilอ่านท้ายสุด : not found · ต้องใช้ API ที่ nil-safe
no required module provides package .../modules/postgresลืม go get module postgres แยกgo get github.com/testcontainers/testcontainers-go/modules/postgres
panic: testing: Short called before Parseเรียก testing.Short() ใน TestMain ก่อน m.Run()เช็คในตัวเทส/SetupSuite แทน

goose / PostgreSQL SQLSTATE

เห็นแบบนี้แปลว่าทำอะไรต่อ
Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND (พ่นทั้งหน้า)ขาด driver/dbstring — goose ถือว่า ใช้คำสั่งผิด ไม่ใช่ errorวาง .env ที่ราก repo
goose run: no migration files foundเชื่อม DB ได้แล้ว แต่ -dir ยังเป็น "."ตั้ง GOOSE_MIGRATION_DIR=./migrations
goose run: ./nope directory does not exist-dir ชี้ path ที่ไม่มีจริงเช็ค path
failed to parse migration: unexpected state 0 on line "..."ลืม -- +goose Up · state 0 = ยังไม่เจอ annotationใส่ annotation · goose validate จับได้ก่อนแตะ DB
unterminated dollar-quoted string at or near "$$ ..." (SQLSTATE 42601)goose ตัด SQL ตรง ; ตัวแรกใน body ของ functionครอบ -- +goose StatementBegin/End
CREATE INDEX CONCURRENTLY cannot run inside a transaction block (SQLSTATE 25001)goose ห่อ transaction ให้โดย defaultใส่ -- +goose NO TRANSACTION บรรทัดแรก
found 1 missing migrations before current version ...merge migration ที่ timestamp เก่ากว่าตัวที่ apply แล้วrebase เปลี่ยนชื่อไฟล์ · หรือ -allow-missing (ทำ version เพี้ยน)
EMPTY 20260818130000_....sql + exit 0ไม่มี block Down — ลบแถว version แต่ DB ไม่ย้อนเขียน Down เสมอ · ติดหล่มแล้วต้องแก้ด้วยมือ
outfile exists (FTL) — mockeryforce-file-write: false และไฟล์ mock มีอยู่แล้วปล่อยไว้ที่ true ตามที่ init ให้มา
SQLSTATE 42703undefined_column — column ไม่มีจริงmock จับไม่ได้ ต้อง integration test
SQLSTATE 42P01undefined_table — ตาราง/relation ไม่มีจริงเช็คชื่อตาราง · migration รันครบหรือยัง
SQLSTATE 23505unique_violation — ชน unique constraintดูชื่อ index ในข้อความ (เช่น idx_users_email)
SQLSTATE 22P02invalid_text_representation — แปลง type ไม่ได้เขียน migration แปลงข้อมูลเป็นขั้น ๆ
SQLSTATE 42601syntax_errorดู SQL ที่ถูกส่งจริงในข้อความ — มักโดนตัดกลางคัน

14 · ตารางกับดักรวม

อาการerror ที่เห็นสาเหตุวิธีแก้
Mockery
รัน mockery แล้วตายทันที ไม่ generate อะไร FTL app failed error="getting config: discovering mockery config: file not found" v3 บังคับต้องมี config เสมอ ไม่มี flag --name/--dir แล้ว mockery init <module_path> ก่อน
รันแล้วเงียบ exit 0 แต่ไม่มีไฟล์ mock INF Done parsing configured packages. แล้วจบ · EXITCODE=0 config ชี้ module root ที่ไม่มีไฟล์ .go — และ mockery init สร้างแบบนี้มาให้เอง เติม recursive: true · หรือเปลี่ยน key เป็น package path เต็ม
เทสตกโดยไม่ได้เขียน assert เอง · ชี้ไป mocks_test.go FAIL: 0 out of 1 expectation(s) were met. The code you are testing needs to make 1 more call(s). constructor ลงทะเบียน t.Cleanup(AssertExpectations) ไว้ให้ อ่าน ตัวท้ายสุดใน [] ตัวแรก = บรรทัดที่ตั้ง EXPECT ไว้
เทสตก + panic หยุดกลางคัน + error ซ้อน 3 ก้อน mock: Unexpected Method Call · Diff: 0: FAIL: (int=8) != (int=7) testify จับคู่ call ด้วยค่า argument ทุกตัว ไม่ตรงเป๊ะ = call ที่ไม่คาดคิด เลข 0 = index ของ arg · ซ้าย = ค่าจริง ขวา = ที่ตั้งไว้ · ไม่สนใจให้ใช้ mock.Anything
เทส panic ทั้ง binary พร้อม stack trace ยาว panic: no return value specified for CountActive EXPECT() แล้วลืม .Return() — mock คืน zero value อย่างปลอดภัยไม่ได้ ต่อ .Return(...) ครบทุก EXPECT · อ่านคำหลัง specified for
เทสไม่ทันได้รัน พังตั้งแต่ compile *MockUserRepository does not implement UserRepository (missing method Delete) แก้ interface แล้วลืม generate ใหม่ — mock เป็นโค้ด static รัน mockery ใหม่ · เจอคำว่า missing method ให้นึกถึง mockery ก่อน
เห็น mock อยู่ในโค้ดชัด ๆ แต่ compile ไม่ผ่าน undefined: user.NewMockUserRepository filename: mocks_test.go เป็น test file — package อื่นมองไม่เห็น เปลี่ยนเป็น filename: mocks.go
เปลี่ยน filename แล้วพังเป็นสิบบรรทัด NewMockUserRepository redeclared in this block mockery เขียนไฟล์ใหม่เฉย ๆ ไม่ลบไฟล์ชื่อเก่าให้ rm user/mocks_test.go ก่อนเปลี่ยนชื่อ
config ที่แก้ไว้หายเกลี้ยง กลับเป็น default ไม่มี error · INF writing to file file=.mockery.yml · EXITCODE=0 mockery init ทับไฟล์เดิมโดยไม่เช็คและไม่เตือน รัน init ครั้งเดียวตอนเริ่มโปรเจกต์ · ไม่มี --force ให้กัน
เรียกซ้ำแล้วเทสตก assert: mock: The method has been called over 1 times. Once() = Times(1) เกินโควตาแล้วหา expectation เหลือไม่เจอ ใช้ .Times(n) หรือไม่ใส่ Once() เลย
ไฟล์ config วางอยู่ตรงหน้าแต่หาไม่เจอ discovering mockery config: file not found ตั้งชื่อ mockery.yml (ไม่มีจุด) — v3 หาเฉพาะ .mockery.yml/.yaml เติมจุดนำหน้า · หรือ mockery --config mockery.yml
compile ผ่านแต่ mock ใช้ไม่ได้ go: finding module for package github.com/stretchr/testify/mock mockery ไม่เติม go.mod ให้ go mod tidy
Testcontainers
docker ps ใช้ได้ แต่เทสล้มใน 0.00 วินาที get provider: rootless Docker not found, failed to create Docker provider testcontainers ไม่อ่าน docker context ของ Docker CLI เลย export DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
ตั้ง DOCKER_HOST ถูกแล้วแต่ยังสร้าง container ไม่ได้ reaper: new reaper: ... mkdir /Users/.../docker.sock: operation not supported Ryuk ต้อง mount socket แต่ path ฝั่ง mac ไม่มีอยู่ใน VM export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
ต่อ DB ไม่ติดแบบสุ่ม — เครื่องแรงพังบ่อย เครื่องช้าผ่าน [::1]:32800: connect: connection refused + 127.0.0.1:32800: failed to receive message: unexpected EOF ไม่ใส่ BasicWaitStrategies() — postgres restart ตัวเองหลัง initdb ใส่เสมอ · unexpected EOF = ต่อเร็วเกินไป ไม่ใช่ "ยังไม่มีอะไรฟัง"
เทสที่ควรเร็ว กลับใช้เวลา 11 วินาที ไม่มี error — Stopping container 20:00:27 → Container stopped 20:00:38 terminate container ที่ยังไม่ ready · postgres ไม่ตอบ SIGTERM ต้องรอ SIGKILL 10 วิ รอ ready ก่อนเสมอ — BasicWaitStrategies() กัน teardown ช้าด้วย
container ขึ้นเรียบร้อยแต่ต่อไม่ติดเลยสักครั้ง dial tcp 127.0.0.1:5432: connect: connection refused (ทั้ง [::1] และ 127.0.0.1) hardcode port 5432 · testcontainers map ไป port สุ่ม (เห็น 32769–32812) ใช้ ConnectionString(ctx, "sslmode=disable") เสมอ · อันตรายกว่า: ถ้าเครื่องมี postgres จริงที่ 5432 เทสจะ "ผ่าน" โดยเขียนทับ DB จริง
เทส fail แล้ว container ค้างทุกครั้ง wait until ready: "..." matched 0 times, expected 1 · returned container is nil? false t.Fatal(err) อยู่ก่อนบรรทัด defer · และ Run คืน container ที่ไม่ nil พร้อม error วาง defer func(){ _ = testcontainers.TerminateContainer(ctr) }() ก่อนเช็ค err
go test -short ยังช้าและยังต้องมี Docker ทั้งที่ SKIP ครบ ไม่มี error — [eager TestMain] container booted BEFORE m.Run() in 1.047302208s boot container ใน TestMain ก่อน m.Run() — มันรันก่อนเทสทุกตัวเสมอ ย้าย boot ไป sync.Once ที่ถูกเรียกจากในตัวเทส (1.673s → 0.353s)
เช็ค -short ใน TestMain แล้ว panic panic: testing: Short called before Parse flag ถูก parse ตอน m.Run() ไม่ใช่ก่อนหน้า เช็คในตัวเทส/SetupSuite ซึ่ง flag parse แล้วแน่นอน
เทสจบแล้ว docker ps ยังเห็น container ค้าง ไม่มี error — t+9s running_pg=1 · t+10s running_pg=0 Ryuk รอ 10 วินาทีหลัง test process ตายแล้วค่อยลบ (รวม anonymous volume ด้วย) ยังต้องเขียน Terminate เอง — Ryuk คือ safety net ไม่ใช่แผนหลัก · ปิด Ryuk = ค้างถาวร
CI เขียว 100% แต่ deploy แล้ว endpoint พังทันที mock: --- PASS · จริง: ERROR: column "city" does not exist (SQLSTATE 42703) go-sqlmock จับคู่ SQL ด้วย regex ไม่มี parser ไม่รู้จัก schema ทุกอย่างที่เป็นสัญญากับ DB ต้องมี integration test
JSON ของ endpoint เป็น null แทน [] ไม่มี error — len=0 isNil=true json=null ใช้ Raw().Scan() หรือ rows.Next() เอง (GORM Find ไม่เป็น) users := make([]T, 0)
เทสตกแล้ว error ชี้บรรทัดเดียวกันหมดทุกเคส helper_test.go:16: got "a" want "b" (บรรทัดในฟังก์ชันช่วย) ฟังก์ชันช่วยไม่ได้เรียก t.Helper() ใส่ t.Helper() บรรทัดแรก → error ชี้บรรทัดที่ 21 ของคนเรียกแทน
Goose
SQL ที่รันใน psql ได้ แต่ goose up พัง unterminated dollar-quoted string at or near "$$ ..." (SQLSTATE 42601) goose แบ่ง statement ด้วย ; ไม่รู้จัก dollar-quoted string ครอบ -- +goose StatementBegin/End เฉพาะ block ที่มี ; ข้างใน
สร้างโฟลเดอร์ migrations/ ไว้แล้ว แต่หาไฟล์ไม่เจอ ไม่มี error — Created new file: 20260818125016_create_products_table.sql -dir default = "." · goose ไม่เดาว่ามีโฟลเดอร์ migrations/ ตั้ง GOOSE_MIGRATION_DIR=./migrations ใน .env
goose down แล้ว up ใหม่ ข้อมูลกลายเป็น 0 ไม่มี error — goose รายงาน OK ทั้งสองครั้ง Down ที่ DROP COLUMN ทำลายข้อมูลถาวร · up เติมค่า default ให้ทุกแถว อย่ารัน down บน production กับ migration ที่ทำลายข้อมูล — เขียนไฟล์ใหม่ (forward-only)
แก้ไฟล์ที่ apply แล้ว รัน up ใหม่ ข้อมูลไม่เพิ่ม แต่เครื่องเพื่อนได้ครบ ไม่มี error — goose: no migrations to run. current version: ... · EXITCODE=0 goose_db_version ไม่มี checksum · goose validate ก็ไม่ช่วย (เช็คแค่ syntax) ไฟล์ที่ commit + apply แล้ว ห้ามแก้เด็ดขาด — สร้างไฟล์ใหม่เสมอ
pull โค้ดมาแล้ว goose up ไม่รันอะไรเลย ทั้งที่ status บอก Pending goose run: error: found 1 missing migrations before current version 20260818125201 branch แยกนาน timestamp เลยเก่ากว่าตัวที่ main รันไปแล้ว goose -allow-missing up (⚠️ ทำ goose version เพี้ยน) · ปลอดภัยกว่าคือ rebase เปลี่ยนชื่อไฟล์
goose down ผ่าน exit 0 แต่ตารางยังอยู่ แล้ว up ใหม่พัง EMPTY 20260818130000_no_annotation.sql แล้ว relation "t1" already exists (SQLSTATE 42P07) ไม่มี block Down → goose ลบแถว version ทิ้งแล้วรายงานสำเร็จ · DB กับ version ไม่ตรงกัน เขียน Down เสมอแม้แค่ DROP TABLE · ติดหล่มแล้วต้อง DROP ด้วยมือก่อน
สร้าง index บนตารางใหญ่แบบไม่ล็อกไม่ได้ CREATE INDEX CONCURRENTLY cannot run inside a transaction block (SQLSTATE 25001) goose ห่อทุกไฟล์ด้วย BEGIN/COMMIT โดย default -- +goose NO TRANSACTION บรรทัดแรก · แลกกับไม่มี rollback → 1 statement/ไฟล์
เปลี่ยนชื่อ field แล้ว AutoMigrate — query ผ่าน GORM ได้ 0 หมด ไม่มี error — AutoMigrate step2 OK · ตารางมีทั้ง price และ cost AutoMigrate "เติมสิ่งที่ขาด" อย่างเดียว ไม่รู้ว่า cost คือ price ที่เปลี่ยนชื่อ ใช้ goose: ALTER TABLE products RENAME COLUMN price TO unit_price; ข้อมูลอยู่ครบ
เติม tag not null แล้ว AutoMigrate ขึ้น OK แต่ DB ไม่มี constraint ไม่มี error — AutoMigrate step3 OK · EXITCODE=0 · คอลัมน์ Nullable ยังว่าง AutoMigrate ไม่ยุ่งกับ NOT NULL บนคอลัมน์เดิม และไม่เตือนเลย อันตรายที่สุด — โค้ดคิดว่ามี constraint แต่ DB ไม่มี · ใช้ goose SET NOT NULL
แอปพังตอน start หลังเปลี่ยน type ใน struct invalid input syntax for type bigint: "ten" (SQLSTATE 22P02) GORM สั่ง ALTER ... USING "stock"::bigint เองโดยไม่ถามว่าข้อมูลเดิมแปลงได้ไหม เขียน goose migration เป็นขั้น: เพิ่มคอลัมน์ใหม่ → UPDATE แปลง → drop ของเก่า
Redis
ระบบ 500 ทุกครั้งที่ cache miss (หนักสุดตอน deploy ใหม่) redis: nil go-redis คืน redis.Nil เป็น error ทั้งที่ miss เป็นเหตุการณ์ปกติ errors.Is(err, redis.Nil) ก่อนเสมอ แล้วค่อยเช็ค err != nil
แอป start ปกติ แล้วพังตอน request แรก + log ท่วม stderr redis: connection pool: failed to dial after 5 attempts: dial tcp 127.0.0.1:55999: connect: connection refused NewClient เป็น lazy connect — ไม่เปิด TCP จนกว่าจะมีคำสั่งแรก rdb.Ping(ctx) ตอน startup แล้ว fail fast · connection error ≠ redis.Nil
เขียน cache แล้วไม่มีอะไรอยู่ใน Redis (EXISTS = 0) redis: can't marshal main.User (implement encoding.BinaryMarshaler) Set รับ any จึง compile ผ่าน แต่แปลง struct/map/slice ไม่ได้ json.Marshal ก่อน แล้วเช็ค err ของ Set ด้วย (error เกิดที่นี่)
ลูกค้าเห็นราคาเก่า รีเฟรชกี่ครั้งก็เหมือนเดิม แล้วอยู่ ๆ ก็หายเอง ไม่มี error เลย — [HIT] ลูกค้าเห็นราคา 300 บาท <-- DB บอก 500 เส้นทางเขียนกับเส้นทางอ่านแยกกัน cache ไม่มีทางรู้ว่าต้นทางเปลี่ยน ทุกจุดที่เขียนข้อมูลต้อง rdb.Del(ctx, key) ด้วย · TTL เป็นแค่ตาข่ายกันพลาด
ทุกครั้งที่ deploy หรือ Redis restart DB CPU พุ่ง 100% ไม่มี error — request=100 DB ถูกยิง 100 ครั้ง cache-aside ไม่มีอะไรกัน goroutine ที่ miss พร้อมกันวิ่งไป DB พร้อมกัน mutex + double-check หลังได้ lock → เหลือ 1 ครั้ง เวลารวมแทบไม่ต่าง
API ที่ไม่เกี่ยวกันเลย latency พุ่งเป็นระลอก ไม่มี error — SLOWLOG: 146086 µs สำหรับ KEYS * Redis ประมวลผลทีละคำสั่งบน thread เดียว · KEYS ต้องเดินทั้ง keyspace ให้จบ ใช้ SCAN วน cursor · max latency 274 ms → 2 ms
job ที่ไล่ลบ key ตาม pattern ลบได้ไม่ครบทุกครั้ง ไม่มี error — SCAN 0 MATCH "u:*" COUNT 2 คืน cursor 3 + key 1 ตัว บรรทัดแรกของผล SCAN คือ cursor · COUNT เป็น hint ไม่ใช่ limit วนจน cursor == 0 · อ่าน len() จริงไม่ยึด COUNT (ขอ 1000 ได้ 1004)
ตั้ง TTL 1 ชั่วโมง แต่ข้อมูลหายในไม่กี่วินาที ไม่มี error — EXISTS 0 · TTL -2 · evicted_keys:190824 ชน maxmemory + policy allkeys-lru → ทิ้ง key โดยไม่สนใจ TTL ที่เหลือ อย่าถือว่า TTL เป็นสัญญา — cache ต้อง reconstruct ได้เสมอ · เฝ้า evicted_keys
cache เต็มแล้วเขียนไม่ได้เลย แต่ยังอ่านได้ปกติ OOM command not allowed when used memory > 'maxmemory'. · Go: *proto.OOMError default maxmemory-policy คือ noeviction — เหมาะกับ database ไม่ใช่ cache CONFIG SET maxmemory-policy allkeys-lru · จับ *proto.OOMError แล้ว alert
เอา TTL ไปตั้ง timer แล้วมันยิงทันที ไม่มี error — TTL = -2ns (-0 วินาที), err = <nil> go-redis เอาเลขดิบ -2 ใส่ time.Duration ซึ่งหน่วยเป็น nanosecond เทียบ ttl == -2 / ttl == -1 ตรง ๆ ก่อน · อย่าเรียก .Seconds() แล้วเทียบ
เทสเทียบ cache กับ DB fail ทั้งที่พิมพ์ออกมาเหมือนกันเป๊ะ ไม่มี error — == ? false Equal()? true · DeepEqual: false time.Time พก monotonic clock (m=+0.000815209) ที่ JSON ไม่มีที่เก็บ · unexported field หายเงียบ ใช้ .Equal() เทียบ time.Time เสมอ ห้ามใช้ == · .Truncate(0) ก่อนเก็บ
ID ที่อ่านผ่าน cache ต่างจาก DB หลักสุดท้าย ไม่มี error — 9007199254740993 → 9007199254740992 unmarshal ลง map[string]any → JSON number กลายเป็น float64 (แม่นถึง 2^53) unmarshal ลง struct ที่ประกาศ int64 · หรือ UseNumber() · หรือส่ง ID เป็น string
cache write หายเฉพาะบางเรคอร์ด ไม่มี log อะไรเลย json: unsupported value: +Inf (ถ้าเช็ค err) คำนวณหารด้วยศูนย์ได้ +Inf/NaN ซึ่ง JSON ไม่รองรับ · คนส่วนใหญ่เขียน b, _ := เช็ค err ของ json.Marshal ทุกครั้ง · sanitize ด้วย math.IsInf/math.IsNaN
สคริปต์ทดลอง Redis พังทุกบรรทัด ทั้งที่ก๊อปคำสั่งไปวางตรง ๆ ได้ zsh: command not found: docker exec day7-redis redis-cli zsh ไม่ word-split ตัวแปรแบบ bash — $R ทั้งก้อนถูกมองเป็นชื่อคำสั่งเดียว ใช้ฟังก์ชันแทนตัวแปร: R() { docker exec day7-redis redis-cli "$@"; }

อ้างอิง