สารบัญ
- Mockery — ติดตั้ง & คำสั่ง
- Mockery —
.mockery.yml - Mockery — เขียนเทสด้วย
EXPECT() - Testcontainers — ติดตั้ง & environment
- Testcontainers — โครงที่ก็อปไปใช้ได้
- Testcontainers —
sync.Once+ testify suite - Goose — คำสั่ง &
.env - Goose — โครงไฟล์ migration
- Redis — คำสั่งใน
redis-cli - Redis — cache-aside ใน Go
- Redis —
KEYSvsSCAN - อันไหนใช้เมื่อไหร่ — mock vs container จริง
- ตารางแปล error ที่เจอบ่อย
- ตารางกับดักรวม
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 |
| สร้าง config | mockery init <module_path> | ได้ .mockery.yml · ทับของเดิมเงียบ ๆ |
| generate mock | mockery เปล่า ๆ | ไม่มี subcommand generate |
| ดู config ที่ใช้จริง | mockery showconfig | รวมค่า default ที่ไม่ได้เขียนไว้ |
| ระบุ config เอง | mockery --config <file> | ใช้ชื่อไฟล์อะไรก็ได้ |
| ย้าย config v2 → v3 | mockery 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-package | recursive: 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) |
| ไม่สนใจ argument | repo.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) |
| สไตล์เดิมจาก v2 | repo.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 -short | boot time |
|---|---|---|
boot container ก่อน m.Run() | 1.673s · ยังต้องมี Docker | 1.047s (เสียเปล่า) |
lazy sync.Once | 0.353s · ไม่แตะ Docker | 0s |
ลำดับ hook ของ testify suite
SetupSuite
SetupTest → BeforeTest(suite, test) → body → AfterTest(suite, test) → TearDownTest
... วนซ้ำทุกเทส ...
TearDownSuite
⚠️ suite รัน method เรียงตามตัวอักษร ไม่ใช่ลำดับที่ประกาศ —
ประกาศ TestZebra ก่อน TestApple แต่ TestApple รันก่อน
→ ห้ามเขียนเทสที่พึ่งพาลำดับ
nil slice — จำเป็นเฉพาะบางเส้นทาง
| เขียนแบบไหน · 0 rows | isNil | JSON |
|---|---|---|
GORM Find เข้า var []T | false | [] |
GORM Find เข้า make([]T,0) | false | [] |
GORM Raw().Scan() | true | null |
database/sql + rows.Next() | true | null |
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 | สร้างไฟล์ SQL | Created new file: migrations/20260818125033_create_products_table.sql |
goose -s create <name> sql | เลขเรียง 00001 แทน timestamp | Created new file: migrations/00001_init.sql |
goose create <name> go | migration แบบ 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 redo | down แล้ว 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
| งาน | AutoMigrate | goose |
|---|---|---|
| สร้างตาราง / เพิ่มคอลัมน์ | ✅ ได้ | ✅ ได้ |
| เปลี่ยน 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 v | OK | — |
SET k v EX 3 | OK + ตั้ง TTL ในคำสั่งเดียว | — |
GET k | ค่า | ว่าง / (nil) |
EXISTS k | 1 | 0 |
TYPE k | string · list · … | none |
DEL k | จำนวน key ที่ลบได้ (ไม่ใช่ OK) | 0 — ลบซ้ำไม่ error |
TTL k | วินาทีที่เหลือ · -1 = ไม่มี expire | -2 |
EXPIRE k 100 | 1 | 0 — ไม่ error |
PERSIST k | 1 (ถอด 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 ถูกยิง | เวลา |
|---|---|---|
| ไม่มี lock | 100 ครั้ง | 269 ms |
mutex + double-check | 1 ครั้ง | 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 keys | KEYS * | SCAN |
|---|---|---|
| เวลาที่ Redis ใช้ต่อคำสั่ง (SLOWLOG) | 146,086 µs | 57 µs |
| เวลารวมฝั่ง Go | 150 ms | 862 ms (วน 1,000 รอบ) |
| latency ของ client อื่น (max) | 274 ms | 2 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 จริงบังคับให้พังยาก |
| เส้นทางที่ ไม่ ควรเรียก dependency | mock | AssertNotCalled · container พิสูจน์ไม่ได้ |
| dependency ที่เป็น API ภายนอก / payment | mock | ยิงของจริงไม่ได้ |
| SQL ใช้ได้กับ schema จริงไหม | container | mock ไม่มี parser ไม่รู้จัก schema |
| column / ตาราง สะกดถูกไหม | container | 42703 · 42P01 จับได้ทันที |
| unique index มีจริงไหม | container | เป็น DDL · 23505 เท่านั้นที่พิสูจน์ได้ |
| migration ของ goose รันผ่านไหม | container | ต้องมี DB จริงถึงจะรัน |
| type ที่ scan กลับมาตรงไหม / nil slice | container | ขึ้นกับ 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: nil | cache miss — เหตุการณ์ปกติ ไม่ใช่ error | errors.Is(err, redis.Nil) แล้วไปอ่าน DB |
dial tcp 127.0.0.1:55999: connect: connection refused | Redis ไม่ได้เปิด / 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 / -1ns | go-redis เอาเลขดิบใส่ time.Duration → หน่วยเป็น ns ไม่ใช่วินาที | เทียบ ttl == -2 / ttl == -1 ตรง ๆ ก่อนคำนวณ |
testcontainers-go
| เห็นแบบนี้ | แปลว่า | ทำอะไรต่อ |
|---|---|---|
get provider: rootless Docker not found, failed to create Docker provider | หา Docker host ไม่เจอ — ไม่ได้แปลว่าปัญหาอยู่ที่ rootless | export 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 supported | Ryuk mount socket ไม่ได้ — path ฝั่ง mac ไม่มีใน VM | export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock |
wait until ready: "..." matched 0 times, expected 1 | wait 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 found | image 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) — mockery | force-file-write: false และไฟล์ mock มีอยู่แล้ว | ปล่อยไว้ที่ true ตามที่ init ให้มา |
SQLSTATE 42703 | undefined_column — column ไม่มีจริง | mock จับไม่ได้ ต้อง integration test |
SQLSTATE 42P01 | undefined_table — ตาราง/relation ไม่มีจริง | เช็คชื่อตาราง · migration รันครบหรือยัง |
SQLSTATE 23505 | unique_violation — ชน unique constraint | ดูชื่อ index ในข้อความ (เช่น idx_users_email) |
SQLSTATE 22P02 | invalid_text_representation — แปลง type ไม่ได้ | เขียน migration แปลงข้อมูลเป็นขั้น ๆ |
SQLSTATE 42601 | syntax_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 "$@"; } |
อ้างอิง
- Mockery Documentation (v3)
- Testcontainers for Go — PostgreSQL module
- testify/suite — hook ของ suite
- Goose Documentation
- PostgreSQL Error Codes — ตาราง SQLSTATE ฉบับเต็ม
- Redis
SCAN— cursor semantics และข้อรับประกัน - Redis configuration —
maxmemory-policy,save,appendonly - go-redis Documentation