15 มี.ค. 2568·อ่าน 2 นาที

Go context timeouts สำหรับ API: จาก HTTP handler ถึง SQL

การใช้ Go context timeouts ช่วยแพร่เดดไลน์จาก HTTP handler ลงไปยังการเรียก SQL ป้องกันคำขอที่ติด และรักษาเสถียรภาพของบริการเมื่อมีโหลดสูง

Go context timeouts สำหรับ API: จาก HTTP handler ถึง SQL

ทำไมคำขอถึงติด (และมันส่งผลอย่างไรตอนมีโหลดสูง)

คำขอจะ "ติด" เมื่อมันรอสิ่งที่ไม่คืนค่า: คิวรีฐานข้อมูลช้า คอนเนคชันในพูลถูกบล็อก ปัญหา DNS หรือตัวบริการภายนอกที่รับการเรียกแต่ไม่ตอบกลับ

อาการดูเรียบง่าย: บางคำขอใช้เวลานานมาก แล้วคำขออื่นๆ เริ่มรออยู่ข้างหลัง คุณมักจะเห็นหน่วยความจำเพิ่มขึ้น จำนวน goroutine เพิ่ม และคิวของคอนเนคชันที่ไม่ลดลง

ตอนมีโหลดสูง คำขอที่ติดส่งผลสองเท่า มันทำให้ worker ถูกผูกอยู่ และมันยึดทรัพยากรที่หายาก เช่น คอนเนคชันฐานข้อมูลและล็อก นั่นทำให้คำขอที่ปกติเร็วกลายเป็นช้า เกิดการทับซ้อนมากขึ้น ซึ่งยิ่งทำให้รอเพิ่มขึ้นอีก

การลองซ้ำ (retry) และการพีกของทราฟฟิกทำให้วงจรนี้แย่ลง ลูกค้า timeout และลองใหม่ในขณะที่คำขอเดิมยังทำงานอยู่ ดังนั้นคุณจ่ายสองครั้ง คูณกับหลายลูกค้าในช่วงช้าสั้นๆ ก็อาจทำให้ฐานข้อมูลโอเวอร์โหลดหรือถึงขีดจำกัดคอนเนคชัน แม้ทราฟฟิกเฉลี่ยจะปกติดี

Timeout คือคำสัญญาง่ายๆ: "เราจะไม่รอนานเกิน X" มันช่วยให้ล้มเร็วและปล่อยทรัพยากร แต่ไม่ทำให้งานเสร็จเร็วขึ้น

มันก็ไม่ได้รับประกันว่างานจะหยุดทันที ตัวอย่างเช่น ฐานข้อมูลอาจยังรันคิวรีต่อ บริการภายนอกอาจไม่สนใจการยกเลิก หรือโค้ดของคุณอาจไม่ปลอดภัยเมื่อเกิดการยกเลิก

สิ่งที่ timeout การันตีได้คือ handler ของคุณสามารถหยุดรอ ตอบกลับด้วยข้อผิดพลาดที่ชัดเจน และปล่อยสิ่งที่ถืออยู่ การรอที่มีขอบเขตแบบนี้ช่วยไม่ให้การเรียกช้าจำนวนหนึ่งกลายเป็นการล่มใหญ่

เป้าหมายของการใช้ Go context timeouts คือเดดไลน์เดียวที่แชร์จากขอบระบบลงไปถึงการเรียกที่ลึกที่สุด ตั้งครั้งเดียวที่ขอบ HTTP ส่ง context เดียวกันผ่านโค้ดบริการของคุณ และใช้มันในคำสั่ง database/sql เพื่อบอกให้ฐานข้อมูลหยุดรอด้วย

Context ใน Go แบบเข้าใจง่าย

context.Context เป็นวัตถุเล็กๆ ที่คุณส่งผ่านโค้ดเพื่ออธิบายสิ่งที่เกิดขึ้นตอนนี้ มันตอบคำถามเช่น: "คำขอนี้ยังหมดอายุไหม?" "เมื่อไหร่เราควรยอมแพ้?" และ "มีค่าระดับคำขออะไรที่ต้องเดินทางกับงานนี้บ้าง?"

ข้อดีใหญ่คือการตัดสินใจครั้งเดียวที่ขอบระบบ (HTTP handler) สามารถปกป้องทุกขั้นตอนถัดไป ตราบเท่าที่คุณส่ง context เดียวกันต่อไป

Context บรรจุอะไรบ้าง

Context ไม่ใช่ที่เก็บข้อมูลธุรกิจ มันสำหรับสัญญาณควบคุมและข้อมูลขนาดเล็กที่เกี่ยวกับคำขอ: การยกเลิก เดดไลน์/timeout และเมตาดาต้าเล็กๆ เช่น request ID สำหรับล็อก

ความต่างระหว่าง timeout กับ cancellation ง่ายมาก: timeout เป็นเหตุผลหนึ่งของการยกเลิก ถ้าคุณตั้ง timeout 2 วินาที context จะถูกยกเลิกเมื่อครบ 2 วินาที แต่ context ก็สามารถถูกยกเลิกเร็วกว่าได้ ถ้าผู้ใช้ปิดแท็บ load balancer หยุดการเชื่อมต่อ หรือโค้ดของคุณตัดสินใจให้คำขอหยุด

Context ไหลผ่านการเรียกฟังก์ชันโดยเป็นพารามิเตอร์ชัดเจน มักจะเป็นตัวแรก: func DoThing(ctx context.Context, ...) นั่นแหละเหตุผล มันยากจะ "ลืม" เมื่อมันปรากฏที่ทุกที่

เมื่อเดดไลน์หมด สิ่งที่เฝ้าดู context นั้นควรหยุดเร็ว ตัวอย่างเช่น คิวรีฐานข้อมูลที่ใช้ QueryContext ควรคืนค่ากลับเร็วด้วยข้อผิดพลาดอย่าง context deadline exceeded และ handler ของคุณจะตอบด้วย timeout แทนที่จะค้างจนเซิร์ฟเวอร์หมด worker

แบบคิดที่ดี: หนึ่งคำขอ หนึ่ง context ส่งต่อไปทุกที่ ถ้าคำขอตาย งานก็ควรตายด้วย

การตั้งเดดไลน์ชัดเจนที่ขอบ HTTP

ถ้าคุณอยากให้ timeout แบบ end-to-end ทำงาน ให้ตัดสินใจว่าชั่วโมงนาฬิกาเริ่มเมื่อไหร่ ตำแหน่งที่ปลอดภัยที่สุดคือขอบ HTTP เพราะการเรียกถัดไปทุกอย่าง (ตรรกะธุรกิจ, SQL, บริการอื่น) จะรับเดดไลน์เดียวกัน

คุณสามารถตั้งเดดไลน์ได้หลายที่ ตั้งค่าเซิร์ฟเวอร์เป็น baseline ที่ดีและปกป้องจากลูกค้าที่ช้า middleware ดีสำหรับความสม่ำเสมอในกลุ่มเส้นทาง การตั้งภายใน handler ก็โอเคเมื่อคุณอยากให้ชัดเจนและท้องถิ่น

สำหรับ API ส่วนใหญ่ timeout ต่อคำขอใน middleware หรือ handler จะเข้าใจง่ายที่สุด จงตั้งให้สมจริง: ผู้ใช้ชอบล้มเร็วด้วยข้อความลัดที่ชัดเจน มากกว่าการค้าง ทีมหลายทีมใช้งบประมาณสั้นกว่าสำหรับอ่าน (เช่น 1-2s) และยาวกว่าเล็กน้อยสำหรับเขียน (เช่น 3-10s) ขึ้นกับหน้าที่ endpoint

นี่คือตัวอย่าง pattern handler แบบง่าย:

func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusGatewayTimeout)
        return
    }

    json.NewEncoder(w).Encode(report)
}

สองกฎช่วยให้วิธีนี้ได้ผล:

  • เรียก cancel() เสมอเพื่อให้ timer และทรัพยากรถูกปล่อยเร็ว
  • อย่าแทนที่ request context ด้วย context.Background() หรือ context.TODO() ภายใน handler เพราะจะทำลายสายส่ง และการเรียกฐานข้อมูลหรือคำขอขาออกอาจรันต่อไปทั้งที่ลูกค้าหายไปแล้ว

การส่งต่อ context ผ่านโค้ดของคุณ

เมื่อคุณตั้งเดดไลน์ที่ขอบ HTTP งานจริงคือทำให้เดดไลน์เดียวกันนั้นไปถึงทุกเลเยอร์ที่อาจบล็อก แนวคิดคือหนึ่งนาฬิกา ใช้ร่วมกันโดย handler, โค้ดบริการ, และทุกอย่างที่แตะเครือข่ายหรือดิสก์

กฎง่ายๆ เพื่อความสม่ำเสมอ: ทุกฟังก์ชันที่อาจรอควรรับ context.Context และควรเป็นพารามิเตอร์ตัวแรก นั่นทำให้ชัดเจนที่จุดเรียก และจะกลายเป็นนิสัย

รูปแบบ signature ที่ใช้งานได้จริง

ชอบ signature แบบ DoThing(ctx context.Context, ...) สำหรับ service และ repository หลีกเลี่ยงการซ่อน context ใน struct หรือตั้งขึ้นใหม่ด้วย context.Background() ในเลเยอร์ล่าง เพราะนั่นจะทำให้เดดไลน์ของ caller หายไปโดยเงียบๆ

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
        // map context errors to a clear client response elsewhere
        http.Error(w, err.Error(), http.StatusRequestTimeout)
        return
    }
}

func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
    // parsing or validation can still respect cancellation
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    return s.repo.InsertOrder(ctx, /* data */)
}

จัดการการออกก่อนเวลาอย่างสะอาด

มอง ctx.Done() เป็นเส้นทางการควบคุมปกติ สองนิสัยช่วยได้:

  • ตรวจ ctx.Err() ก่อนเริ่มงานที่แพง และหลัง loop ที่ยาว
  • ส่ง ctx.Err() ขึ้นไปโดยไม่เปลี่ยนแปลง เพื่อให้ handler ตอบกลับเร็วและไม่เสียทรัพยากรต่อ

เมื่อทุกเลเยอร์ส่งต่อ ctx เดียวกัน เดดไลน์เดียวสามารถตัดการแปลผล parsing ตรรกะธุรกิจ และการรอฐานข้อมูลพร้อมกันได้

นำเดดไลน์ไปใช้กับ database/sql

ปล่อยในสภาพแวดล้อมที่ระบบของคุณรันได้
ปล่อยแอปไปยัง AppMaster Cloud หรือ AWS, Azure, Google Cloud ของคุณ
ปรับใช้แอป

เมื่อ handler ของคุณมีเดดไลน์ ให้แน่ใจว่างานฐานข้อมูลก็ฟังมันด้วย ใน database/sql นั่นหมายถึงการใช้เมธอดที่รับ context ทุกครั้ง ถ้าคุณเรียก Query() หรือ Exec() โดยไม่มี context API ของคุณอาจยังรอคิวรีช้าแม้ลูกค้าจะยอมแพ้แล้ว

ใช้เมธอดเหล่านี้เสมอ: db.QueryContext, db.QueryRowContext, db.ExecContext, และ db.PrepareContext (แล้วใช้ QueryContext/ExecContext บน statement ที่ได้กลับมา)

func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
	row := s.db.QueryRowContext(ctx,
		`SELECT id, email FROM users WHERE id = $1`, id,
	)
	var u User
	if err := row.Scan(&u.ID, &u.Email); err != nil {
		return nil, err
	}
	return &u, nil
}

func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE users SET email = $1 WHERE id = $2`, email, id,
	)
	return err
}

มีสองอย่างที่มักพลาดได้ง่าย

อันดับแรก ไดรเวอร์ SQL ของคุณต้องยอมรับการยกเลิกจาก context หลายๆ ตัวรองรับ แต่ยืนยันในสแต็กของคุณด้วยการทดสอบคิวรีช้าๆ แล้วดูว่ามันยกเลิกเร็วเมื่อเดดไลน์หมดหรือไม่

อันดับสอง ควรพิจารณาการตั้ง timeout ฝั่งฐานข้อมูลเป็นแบ็กซ็อป เช่น Postgres สามารถบังคับ statement timeout ได้ นั่นช่วยปกป้องฐานข้อมูลแม้โค้ดจะลืมส่ง context

เมื่อการทำงานหยุดเพราะ timeout ให้จัดการต่างจากข้อผิดพลาด SQL ปกติ ตรวจ errors.Is(err, context.DeadlineExceeded) และ errors.Is(err, context.Canceled) และคืนคำตอบที่ชัดเจน (เช่น 504) แทนที่จะถือว่า "ฐานข้อมูลพัง" หากคุณสร้าง backend Go (เช่น กับ AppMaster) การแยกทางเดินข้อผิดพลาดเหล่านี้ทำให้ logs และ retry ตรงไปตรงมาขึ้น

การเรียกใช้งาน downstream: HTTP clients, cache และบริการอื่นๆ

แม้ handler และ SQL จะเคารพ context คำขอก็ยังสามารถค้างได้ถ้าการเรียก downstream รอไม่จบ ภายใต้โหลด goroutine ที่ติดไม่กี่ตัวก็จะทับซ้อน กินพูลคอนเนคชัน และเปลี่ยนความช้าจิ๋วเป็นการล่มใหญ่ การแก้คือการส่งต่อที่สม่ำเสมอพร้อม backstop ที่ชัดเจน

HTTP ขาออก

เมื่อต้องเรียก API อื่น ให้สร้าง request ด้วย context เดียวกันเพื่อให้เดดไลน์และการยกเลิกเดินทางอัตโนมัติ

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)

อย่าพึ่งพา context เพียงอย่างเดียว ตั้งค่า client และ transport ด้วยเพื่อปกป้องถ้าหากโค้ดใช้ background context โดยไม่ได้ตั้งใจ หรือ DNS/TLS/idle connections หยุดนิ่ง กำหนด http.Client.Timeout เป็นขอบบนสุดสำหรับการเรียกทั้งหมด ตั้ง timeout ของ transport (dial, TLS handshake, response header) และใช้ client เดียวซ้ำๆ แทนสร้างใหม่ทุกคำขอ

Cache และคิว

Cache, message broker และ RPC client มักมีจุดรอเป็นของตัวเอง: การได้คอนเนคชัน รอคำตอบ บล็อกบนคิวเต็ม หรือรอล็อก ให้แน่ใจว่า operation เหล่านั้นรับ ctx และใช้ timeout ระดับไลบรารีเมื่อมีให้ใช้

กฎปฏิบัติ: ถ้าคำขอของผู้ใช้เหลือ 800ms อย่าเริ่มการเรียก downstream ที่อาจใช้ 2 วินาที ให้ข้าม ลดรูป หรือคืนผลบางส่วน

ตัดสินล่วงหน้าว่า timeout สำหรับ API คุณหมายถึงอะไร บางครั้งคำตอบที่ถูกต้องคือ error ที่เร็ว บางครั้งคือข้อมูลบางส่วนสำหรับฟิลด์ที่เป็นทางเลือก บางครั้งคือข้อมูลจาก cache ที่เก่ากว่าและทำเครื่องหมายชัดเจน

ถ้าคุณสร้าง backend Go (รวมถึงที่ generate ได้ อย่างเช่น AppMaster) นี่คือความต่างระหว่าง "มี timeout" กับ "timeout ปกป้องระบบอย่างสม่ำเสมอ" ตอนเกิดพีกของทราฟฟิก

ขั้นตอนทีละขั้น: รีแฟคเตอร์ API ให้ใช้ timeout ครบวงจร

ทำให้การลองซ้ำปลอดภัยยิ่งขึ้นภายใต้โหลด
ออกแบบ endpoints ที่ล้มเร็วและคืนทรัพยากรด้วยเดดไลน์คำขอที่สม่ำเสมอ
สร้าง API

การรีแฟคเตอร์เพื่อ timeout ลงมาที่นิสัยเดียว: ส่ง context.Context เดียวกันจากขอบ HTTP ลงไปถึงทุกการเรียกที่อาจบล็อก

วิธีปฏิบัติที่ทำได้จริงคือทำจากบนลงล่าง:

  • เปลี่ยน handler และเมธอดหลักของบริการให้รับ ctx context.Context
  • อัพเดตทุกคำสั่ง DB ให้ใช้ QueryContext หรือ ExecContext
  • ทำแบบเดียวกันกับการเรียกภายนอก (HTTP clients, cache, queue). ถ้าไลบรารีไม่รับ ctx ให้ห่อหรือเปลี่ยน
  • ตัดสินว่าใครเป็นเจ้าของ timeout โดยทั่วไป: handler ตั้งเดดไลน์โดยรวม; เลเยอร์ล่างตั้งเดดไลน์สั้นกว่าเมื่อจำเป็น
  • ทำให้ข้อผิดพลาดคาดเดาได้ที่ขอบ: แมป context.DeadlineExceeded และ context.Canceled เป็นการตอบ HTTP ที่ชัดเจน

นี่คือรูปแบบที่คุณต้องการในแต่ละเลเยอร์:

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    _ = json.NewEncoder(w).Encode(order)
}

func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
    row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
    // scan...
}

ค่า timeout ควรน่าเบื่อและสม่ำเสมอ ถ้า handler มีเวลา 2 วินาที ให้ DB queries อยู่ภายใต้ 1 วินาทีเพื่อเผื่อเวลา JSON encoding และงานอื่นๆ

เพื่อพิสูจน์ว่ามันใช้ได้ ให้เพิ่มเทสต์ที่บังคับให้เกิด timeout วิธีง่ายคือ repository ปลอมที่บล็อกจน ctx.Done() แล้วคืน ctx.Err() เทสต์ของคุณควรยืนยันว่า handler คืน 504 อย่างรวดเร็ว ไม่ใช่หลัง delay ที่เทียมขึ้น

ถ้าคุณสร้าง backend Go ด้วย generator (เช่น AppMaster) กฎก็เหมือนกัน: request context เดียว ถูกส่งต่อทุกที่ และมีการกำหนดความเป็นเจ้าของเดดไลน์ชัดเจน

การสังเกตการณ์: พิสูจน์ว่า timeouts ทำงานจริง

ออกแบบข้อมูล Postgres แบบภาพ
ออกแบบสคีมาใน Data Designer และเชื่อมต่อกับตรรกะ API ของคุณ
ออกแบบข้อมูล

Timeout จะช่วยได้เฉพาะเมื่อคุณเห็นมันเกิดขึ้น เป้าหมายง่ายๆ: ทุกคำขอมีเดดไลน์ และเมื่อล้มคุณต้องบอกได้ว่าเวลาหมดไปที่ไหน

เริ่มจากล็อกที่ปลอดภัยและมีประโยชน์ แทนการ dump body ทั้งหมด ให้ล็อกพอเชื่อมจุดและเห็นเส้นทางช้า: request ID (หรือ trace ID), ว่ามีเดดไลน์หรือไม่และเหลือเวลาที่จุดสำคัญเท่าไร ชื่อ operation (handler, ชื่อ SQL query, ชื่อการเรียกขาออก) และหมวดผลลัพธ์ (ok, timeout, canceled, other error)

เพิ่มเมตริกจุดสำคัญเพื่อให้พฤติกรรมภายใต้โหลดชัดเจน:

  • นับการหมดเวลาแยกตาม endpoint และ dependency
  • latency ของคำขอ (p50/p95/p99)
  • คำขอที่กำลังดำเนินการ
  • latency ของ DB query (p95/p99)
  • อัตราข้อผิดพลาดแยกตามประเภท

เมื่อจัดการข้อผิดพลาด ให้ติดแท็กให้ถูก context.DeadlineExceeded มักหมายความว่าคุณชนงบประมาณของตัวเอง context.Canceled มักหมายความว่าลูกค้าหายไปหรือ upstream timeout เกิดก่อน แยกปัญหาเหล่านี้เพราะการแก้ต่างกัน

Tracing: หาจุดที่เสียเวลา

span ของ tracing ควรติดตาม context เดียวกันจาก HTTP handler เข้าไปยัง database/sql เช่น QueryContext หากคำขอ timeout ที่ 2 วินาทีและ trace แสดงว่าใช้ 1.8 วินาทีรอคอนเนคชัน DB นั่นชี้ปัญหาไปที่ขนาดพูลหรือธุรกรรมช้า ไม่ใช่แค่ text ของ query

ถ้าคุณทำแดชบอร์ดภายใน (timeouts ตาม route, top slow queries) เครื่องมือแบบ no-code อย่าง AppMaster สามารถช่วย deploy ได้เร็วโดยไม่ต้องทำ observability เป็นโปรเจ็กต์แยก

ข้อผิดพลาดทั่วไปที่ทำให้ timeout ไร้ผล

บั๊ก "มันยังค้างบางครั้ง" มาจากข้อผิดพลาดเล็กๆ เหล่านี้

  • ตั้งนาฬิกาใหม่กลางทาง handler ตั้งเดดไลน์ 2s แต่ repository สร้าง context ใหม่ที่มี timeout ของตัวเอง (หรือไม่มีเลย) ตอนนั้น DB อาจยังรันหลังจาก client หาย โปรดส่ง ctx เข้าไปและค่อย ๆ ทำให้สั้นลงเมื่อมีเหตุผลชัดเจน
  • สตาร์ท goroutine แล้วไม่หยุด การสตาร์ทงานด้วย context.Background() (หรือละทิ้ง ctx) หมายความว่างานจะยังรันแม้คำขอถูกยกเลิก ให้ส่ง request ctx เข้าไปใน goroutine และ select บน ctx.Done()
  • เดดไลน์สั้นเกินไปสำหรับทราฟฟิกจริง 50ms อาจโอเคบนแลปแต่ล้มใน production ตอนพีก เลือก timeout ตาม latency ปกติบวกเฮดรูม
  • ซ่อนข้อผิดพลาดจริง การทำ context.DeadlineExceeded เป็น 500 ทั่วไป ทำให้ debug และพฤติกรรมของลูกค้าแย่ ควรแมปเป็นการตอบ timeout ชัดเจนและบันทึกความต่างระหว่าง "ยกเลิกโดยลูกค้า" กับ "หมดเวลา"
  • ทิ้งทรัพยากรเปิดตอนออกก่อน หากคืนก่อนเวลา ให้แน่ใจว่ายัง defer rows.Close() และเรียกฟังก์ชัน cancel จาก context.WithTimeout ทรัพยากรที่รั่วหรืองานที่ค้างสามารถทำให้คอนเนคชันหมดภายใต้โหลดได้

ตัวอย่างเร็ว: endpoint เรียก report query ถ้าผู้ใช้ปิดแท็บ handler ctx ถูกยกเลิก ถ้า SQL ใช้ background context คิวรีก็ยังรัน ผูกคอนเนคชันแล้วทำให้คนอื่นช้าลง เมื่อส่ง ctx เดียวกันเข้า QueryContext การเรียกฐานข้อมูลจะถูกตัดและระบบฟื้นตัวเร็วขึ้น

เช็คลิสต์ด่วนสำหรับพฤติกรรม timeout ที่เชื่อถือได้

สร้าง API ที่มีเดดไลน์ชัดเจน
สร้าง backend Go ใน AppMaster และทำให้เดดไลน์คงที่ตั้งแต่ handler ถึง SQL
ลอง AppMaster

Timeout ช่วยได้เฉพาะเมื่อสม่ำเสมอ เรียกพลาดแค่ครั้งเดียวก็อาจผูก goroutine คอนเนคชัน DB และทำให้คำขอถัดไปช้า

  • ตั้งเดดไลน์ชัดเจนที่ขอบ (โดยทั่วไปคือ HTTP handler). ทุกอย่างในคำขอควรสืบทอดมัน
  • ส่ง ctx เดียวกันผ่าน service และ repository layer หลีกเลี่ยง context.Background() ในโค้ดที่เกี่ยวกับคำขอ
  • ใช้เมธอด DB ที่มี context ทุกที่: QueryContext, QueryRowContext, ExecContext
  • ผูก ctx เดียวกับการเรียกขาออก (HTTP clients, cache, queue). ถ้าสร้าง child context ให้สั้นกว่า ไม่ยาวกว่า
  • จัดการการยกเลิกและ timeout อย่างสม่ำเสมอ: คืนข้อผิดพลาดสะอาด หยุดงาน และหลีกเลี่ยง retry loop ภายในคำขอที่ถูกยกเลิก

หลังจากนั้น ยืนยันพฤติกรรมภายใต้แรงกดดัน timeout ที่ถูกทริกเกอร์แล้วแต่ไม่ปล่อยทรัพยากรให้เร็วพอ ยังคงทำร้ายความน่าเชื่อถือ

แดชบอร์ดควรทำให้ timeout ชัดเจน ไม่ซ่อนในค่าเฉลี่ย ติดตามสัญญาณตอบคำถาม "เดดไลน์ถูกบังคับจริงไหม?": การหมดเวลาของคำขอและ DB (แยกกัน), latency percentiles (p95, p99), สถานะพูล DB (in-use connections, wait count, wait duration), และการแจกแจงสาเหตุข้อผิดพลาด (context deadline exceeded กับความล้มเหลวอื่นๆ)

ถ้าคุณสร้างเครื่องมือภายในบนแพลตฟอร์มอย่าง AppMaster ข้อควรปฏิบัติเหล่านี้ใช้ได้กับทุกบริการ Go ที่คุณเชื่อม: กำหนดเดดไลน์ที่ขอบ ส่งต่อ และยืนยันจากเมตริกว่าคำขอที่ติดกลายเป็นล้มเร็วแทนที่จะทับซ้อนช้า

สถานการณ์ตัวอย่างและขั้นตอนถัดไป

ที่มักได้ประโยชน์คือ endpoint ค้นหา สมมติ GET /search?q=printer ช้าลงเมื่อ DB ถูกใช้งานกับ query รายงานขนาดใหญ่ ถ้าไม่มีเดดไลน์ คำขอที่เข้ามาแต่ละอันอาจนั่งรอคิวรี DB ยาวๆ ภายใต้โหลด คำขอที่ติดเหล่านั้นจะกอง ทับ goroutine และคอนเนคชัน แล้ว API ทั้งหมดยังรู้สึกค้าง

เมื่อมีเดดไลน์ชัดเจนใน HTTP handler และส่ง ctx เดียวกันลง repo ระบบจะหยุดรอเมื่องบหมด เมื่อเดดไลน์มาถึง ไดรเวอร์ฐานข้อมูลจะยกเลิกคิวรี (เมื่อรองรับ) handler คืนค่า และเซิร์ฟเวอร์ยังสามารถให้บริการคำขอใหม่ได้แทนที่จะรอคำขอเก่า

พฤติกรรมที่ลูกค้าเห็นดีขึ้นแม้เมื่อเกิดปัญหา แทนที่จะหมุนค้าง 30–120 วินาทีแล้วล้มแบบยุ่งเหยิง ลูกค้าจะได้ error ที่เร็วและคาดเดาได้ (มักเป็น 504 หรือ 503 พร้อมข้อความสั้นๆ เช่น "request timed out") ที่สำคัญกว่านั้น ระบบฟื้นตัวเร็วกว่าเพราะคำขอใหม่ไม่ติดอยู่หลังคำขอเก่า

ขั้นตอนถัดไปให้ติดเป็นนิสัยทั่ว endpoint และทีม:

  • เลือก timeout มาตรฐานตามประเภท endpoint (search vs writes vs exports)
  • บังคับ QueryContext และ ExecContext ใน code review
  • ทำให้ข้อผิดพลาด timeout ชัดเจนที่ขอบ (status code ชัด ข้อความเรียบง่าย)
  • เพิ่มเมตริกสำหรับ timeout และการยกเลิกเพื่อสังเกต regression เร็ว
  • เขียน helper หนึ่งตัวที่ห่อการสร้าง context และการล็อก เพื่อให้ handler ทุกตัวทำงานเหมือนกัน

ถ้าคุณสร้างบริการและเครื่องมือภายในด้วย AppMaster คุณสามารถใช้กฎ timeout เหล่านี้สม่ำเสมอทั้งใน Go backends ที่ generate ได้ การผสาน API และแดชบอร์ด AppMaster กล่าวถึง AppMaster และ appmaster.io เป็นแหล่งข้อมูลสำหรับการสร้างเครื่องมือภายในแบบ no-code ที่ส่งออกเป็นโค้ด Go ได้จริง

คำถามที่พบบ่อย

What does it mean when a request gets “stuck” in a Go API?

คำขอถือว่า “ติด” เมื่อรอกับสิ่งที่ไม่คืนค่า เช่น คิวรี SQL ช้า คอนเนคชันในพูลถูกบล็อก ปัญหา DNS หรือบริการภายนอกที่รับคำขอแต่ไม่ตอบกลับ ภายใต้โหลด คำขอที่ติดกันจะทับซ้อนกัน ผูกงานของ worker และคอนเนคชัน ทำให้ระบบกว้างขึ้นเป็นปัญหาใหญ่ได้

Where should I set the timeout: middleware, handler, or deeper in the code?

ควรกำหนดเดดไลน์โดยรวมที่ขอบระบบ (HTTP boundary) และส่ง ctx เดียวกันไปยังทุกเลเยอร์ที่อาจรอได้ เดดไลน์ร่วมนี้ช่วยป้องกันไม่ให้การดำเนินการช้าบางอย่างผูกทรัพยากรจนลุกลามเป็นปัญหาใหญ่

Why do I need to call `cancel()` if the timeout will fire anyway?

ใช้ ctx, cancel := context.WithTimeout(r.Context(), d) และต้อง defer cancel() เสมอใน handler (หรือ middleware) การเรียก cancel จะช่วยปล่อย timer และทรัพยากรเมื่อคำขอจบก่อนเวลา

What’s the biggest mistake that makes timeouts useless?

อย่าแทนที่ด้วย context.Background() หรือ context.TODO() ในโค้ดที่เกี่ยวกับคำขอ เพราะจะทำให้การยกเลิกและเดดไลน์ขาดหาย หากทิ้ง request context ไว้ downstream อย่าง SQL หรือ HTTP อาจยังทำงานต่อแม้ลูกค้าจะปิดการเชื่อมต่อแล้ว

How should I handle `context deadline exceeded` vs `context canceled`?

ปฏิบัติต่อ context.DeadlineExceeded และ context.Canceled เป็นผลลัพธ์ปกติของการควบคุมการทำงาน และส่งขึ้นไปด้านบนโดยไม่เปลี่ยนแปลง ที่ขอบระบบแมปเป็นการตอบกลับที่ชัดเจน (มักจะ 504 สำหรับ timeout) เพื่อไม่ให้ลูกค้าลองซ้ำโดยไม่รู้ตัว

Which `database/sql` calls should use context?

เรียกใช้เมธอดที่รองรับ context ทุกที่: QueryContext, QueryRowContext, ExecContext, และ PrepareContext ถ้าใช้ Query() หรือ Exec() โดยไม่มี context คำขออาจ timeout แต่การเรียกฐานข้อมูลยังคงบล็อก goroutine และคอนเนคชันไว้

Does canceling a context actually stop a running PostgreSQL query?

ไดรเวอร์หลายตัวรองรับการยกเลิก query แต่ควรทดสอบในสแต็กของคุณด้วยการรันคิวรีที่ช้าจนตั้งใจแล้วดูว่ามันยกเลิกเร็วเมื่อเดดไลน์หมดหรือไม่ นอกจากนี้การตั้ง statement timeout ด้านฐานข้อมูลเป็นแบ็กซ็อปที่ดีในกรณีที่บางเส้นทางลืมส่ง ctx

How do I apply the same deadline to outbound HTTP calls?

สร้างคำขอขาออกด้วย http.NewRequestWithContext(ctx, ...) เพื่อให้เดดไลน์และการยกเลิกเดินทางไปด้วย นอกจากนี้ตั้งค่า timeout ของ http.Client และ transport เป็นขอบบนสุด เพราะ context จะไม่ปกป้องถ้าโค้ดใช้ background context โดยไม่ได้ตั้งใจหรือเกิดการ stall ระดับล่าง (เช่น DNS/TLS)

Should lower layers (repo/services) create their own timeouts?

หลีกเลี่ยงการสร้าง context ใหม่ที่ยืดเวลาบัดเจ็ตในเลเยอร์ล่าง child context ควรสั้นกว่า ไม่ยาวกว่า ถ้าเหลือเวลาในคำขอน้อย อย่าเรียก downstream ที่อาจกินเวลานาน ให้ตัดออก ลดรูป หรือส่ง partial data ตามที่เหมาะสม

What should I monitor to prove end-to-end timeouts are working?

ติดตามการหมดเวลาและการยกเลิกแยกตาม endpoint และ dependency พร้อม percentile latency และคำขอที่กำลังดำเนินการ ใน tracing ให้ส่งต่อ context เดียวกันจาก handler ไปยังการเรียกข้างนอกและ QueryContext เพื่อดูว่าระยะเวลาไปหมดที่ไหน เช่น รอคอนเนคชัน DB หรือรันคิวรี

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม