Hình dung giữ thời gian là dùng hai cái đồng hồ khác hẳn nhau: một cái đồng hồ treo tường kèm tờ lịch — nói "bây giờ là mấy giờ, ngày nào", chỉnh được, và một "ngày" có khi chỉ 23 giờ; và một cái đồng hồ bấm giây — chỉ chạy tới, đo xem đã trôi qua bao lâu. Gói time của Go thiết kế tốt hơn Date cũ của Java, nhưng vẫn có vài chỗ đặc trưng cần biết — và gần như chỗ nào cũng là "bạn cầm nhầm đồng hồ".

Duration là một kiểu

d := 90 * time.Second
  90*time.Second = 1m30s | .Minutes()=1.5 | int64=90000000000

Duration là int64 đếm nano giây, có kiểu riêng nên đơn vị không lẫn được. In ra tự động thành dạng người đọc được.

Cái bẫy hay gặp:

n := 30
d := n * time.Second        // LỖI BIÊN DỊCH
d := time.Duration(n) * time.Second   // đúng

30 * time.Second chạy được vì 30 là hằng không kiểu (bài 4), còn n là biến int — và bài 5 đã nói Go không ép kiểu ngầm.

Layout định dạng: 2006-01-02

t.Format("02/01/2006 15:04")
  13/08/2026 14:30

Go không dùng yyyy-MM-dd. Nó dùng một thời điểm tham chiếu cụ thể:

  Mon Jan 2 15:04:05 MST 2006
   1   2   3  4  5  6      7

Bạn viết thời điểm đó theo định dạng mong muốn, và Go suy ra quy tắc. Ban đầu rất khó nhớ, nhưng đổi lại là không cần thuộc bảng mã ký tự.

Dùng hằng có sẵn khi được: time.RFC3339 cho API, time.DateOnly và time.TimeOnly từ Go 1.20.

So sánh Time bằng == thì sai

  t.Equal(t.In(vn)) = true      <- cùng thời điểm
  t == t.In(vn)     = false     <<< khác

== so cả thời điểm, múi giờ, và phần monotonic clock. Hai giá trị cùng chỉ một khoảnh khắc nhưng khác múi giờ sẽ không bằng nhau — hai cái đồng hồ treo tường chỉ đúng một khoảnh khắc nhưng đặt ở hai múi giờ thì không phải "cùng một cái đồng hồ".

Luôn dùng Equal, Before, After. Đây đúng bài học ở bài 62 sê-ri Java với ZonedDateTime.

Cùng lý do, Time không nên dùng làm khoá map.

Cộng ngày khác cộng 24 giờ

  gốc            : 28/03 12:00 GMT
  AddDate(0,0,1) : 29/03 12:00 BST
  Add(24h)       : 29/03 13:00 BST   <<< lệch 1 giờ

Ngày 29/03 ở London chỉ có 23 giờ vì đổi sang giờ mùa hè.

AddDate làm việc theo lịch — "cùng giờ ngày mai", tức tờ lịch treo tường. Add(24*time.Hour) làm việc theo thời gian tuyệt đối — đúng 86400 giây, tức cái đồng hồ bấm giây.

Chọn theo ý định: nhắc nhở "mỗi ngày 8 giờ sáng" là AddDate; token "hết hạn sau 24 giờ" là Add.

Việt Nam không có giờ mùa hè nên nhiều người Việt chưa từng gặp — cho tới khi làm sản phẩm cho thị trường châu Âu hoặc Mỹ.

Múi giờ

vn, err := time.LoadLocation("Asia/Ho_Chi_Minh")

Luôn dùng tên vùng IANA, không dùng offset cố định. time.FixedZone("GMT+7", 7*3600) tình cờ đúng cho Việt Nam nhưng sai về nguyên tắc.

Một cái bẫy triển khai: LoadLocation đọc cơ sở dữ liệu múi giờ của hệ điều hành. Ảnh Docker tối giản không có tzdata thì nó trả lỗi. Hai cách chữa: cài tzdata vào ảnh, hoặc nhúng vào binary:

import _ "time/tzdata"

Dòng đó thêm khoảng 450KB vào binary và bỏ hẳn phụ thuộc vào hệ điều hành — rất đáng cho ảnh scratch.

Và nhớ đặt TZ trong container, đúng bài học ở bài 97 sê-ri Java: mặc định là UTC, nên cron chạy 6 giờ sáng giờ Việt Nam sẽ thấy máy chủ đang là 23 giờ hôm trước.

Monotonic clock

  time.Since(t0) = 21ms
  Now() có phần m=+...

time.Now() mang theo hai đồng hồ: wall clock (có thể nhảy khi NTP chỉnh) và monotonic (chỉ tăng) — đúng cái đồng hồ treo tường và cái đồng hồ bấm giây ở đầu bài, nằm chung trong một giá trị.

Sub, Since, Until dùng monotonic, nên đo khoảng thời gian không bao giờ ra số âm vì đồng hồ hệ thống bị chỉnh. Đây là thứ Java phải dùng System.nanoTime() riêng mới có.

Nhưng phần monotonic mất khi bạn Round, Truncate, In, hoặc serialize. Nên đừng đo khoảng thời gian bằng Time đã đi qua JSON.

Ba cái bẫy Timer

Quên Stop():

tk := time.NewTicker(time.Second)
defer tk.Stop()

Ticker không dừng là goroutine và timer rò rỉ. Timer cũng nên Stop nếu bạn thoát sớm.

time.After trong vòng lặp — bài 33 đã nói: mỗi vòng tạo một timer mới. Từ Go 1.23, GC dọn được timer chưa hết hạn nên nhẹ đi nhiều, nhưng thói quen vẫn nên giữ.

Ticker không bù nhịp bị lỡ. Nếu xử lý lâu hơn chu kỳ, nhịp bị bỏ qua chứ không dồn lại. Thường đó là hành vi bạn muốn — nhưng phải biết.

Test mã phụ thuộc thời gian

Đừng gọi time.Now() rải rác. Tiêm một hàm:

type Dich struct {
	now func() time.Time
}

func New() *Dich { return &Dich{now: time.Now} }
// trong test: d.now = func() time.Time { return coDinh }

Đây là tương đương Clock của Java ở bài 61, và nó làm logic hết hạn test được mà không phải chờ.

Nếu chỉ thử một thứ sau bài này, cho một Time đi qua JSON rồi so hai kiểu trong ba mươi giây:

t := time.Now()
j, _ := json.Marshal(t)
var t2 time.Time
json.Unmarshal(j, &t2)
fmt.Println(t == t2, t.Equal(t2))

In ra false true. Qua JSON, phần monotonic mất và múi giờ có thể đổi — nhưng thời điểm vẫn đúng. Ba mươi giây đó là lý do Equal tồn tại.

Mẫu số chung

Một dấu thời gian không phải một con số: nó là thời gian dân dụng — có múi giờ, theo lịch, chỉnh được — chồng lên thời gian vật lý, một phép đếm giây chỉ-tăng; và phần lớn lỗi thời gian là trộn hai thứ đó. Java tách chúng ra (Instant/ZonedDateTime so với System.nanoTime), .NET cũng vậy (DateTimeOffset), Python cũng vậy (datetime có so với không múi giờ, time.monotonic). "Một ngày" là đơn vị dân dụng đôi khi 23 hay 25 giờ vì giờ mùa hè; "24 giờ" là một quãng vật lý — dùng số học lịch cho lịch trình của con người, dùng khoảng tuyệt đối cho timeout. Và đo thời gian trôi qua bằng đồng hồ monotonic, không bao giờ bằng đồng hồ tường — cái tường nhảy khi NTP hay DST chỉnh và có thể biến một khoảng thành số âm, đúng lý do nền tảng nào cũng mọc ra một đồng hồ bấm giây riêng.

Điều thứ hai: giữ và so thời gian ở dạng chuẩn tắc. So bằng giá trị (Equal/Before/After), không bằng đồng nhất đối tượng hay bằng bit; lưu UTC cộng một tên vùng IANA, không phải offset cố định hay một chuỗi không múi giờ; và biết rằng ranh giới làm rụng thông tin — phần monotonic mất khi serialize, múi giờ mất trong một định dạng trơ. Đây đúng bài học biểu-diễn-chuẩn-tắc-và-hợp-đồng-tường-minh như tiền là-số-nguyên và ISO-8601 ở bài REST: một thời gian âm thầm chỉ sang một khoảnh khắc khác về sau — một offset +7 cố định mù trước thay đổi luật DST tương lai, một datetime không múi giờ — là một con bọ đóng sẵn trong dữ liệu của bạn. Hãy tường minh mình đang cầm loại thời gian nào, giữ nó chuẩn tắc (UTC + IANA), và so bằng giá trị.

Ngày mai: viết test kiểu Go — bảng test và những gì thư viện chuẩn đã đủ.

Bài tập làm thử

Bài 1 (đọc hiểu). Giải thích tại sao đoạn mã sau bị lỗi biên dịch, và tại sao đoạn 90 * time.Second ở phần đầu bài lại biên dịch được bình thường:

n := 30
d := n * time.Second
Đáp án

Lỗi biên dịch vì n là một biến có kiểu int xác định, còn time.Second là kiểu time.Duration (dựa trên int64) — Go không cho phép nhân hai giá trị có kiểu số khác nhau mà không chuyển kiểu tường minh (không ép kiểu ngầm). Ngược lại, 90 * time.Second biên dịch được vì 90 là một hằng số không kiểu (untyped constant) — trình biên dịch tự động gán cho nó kiểu time.Duration của toán hạng còn lại. Sửa đoạn có lỗi bằng chuyển kiểu tường minh:

d := time.Duration(n) * time.Second

Bài 2 (sửa lỗi). Đoạn mã sau muốn kiểm tra hai thời điểm có bằng nhau không (cùng một khoảnh khắc, dù khác múi giờ biểu diễn), nhưng luôn cho kết quả sai:

vn, _ := time.LoadLocation("Asia/Ho_Chi_Minh")
t1 := time.Now()
t2 := t1.In(vn)
if t1 == t2 {
	fmt.Println("cùng thời điểm")
} else {
	fmt.Println("khác")
}
Đáp án

Luôn in ra "khác" dù t1 và t2 là cùng một khoảnh khắc vật lý — chỉ khác cách biểu diễn múi giờ. == so sánh cả thời điểm, múi giờ (location), và phần monotonic clock; hai Time biểu diễn cùng khoảnh khắc nhưng có location khác nhau sẽ không bằng nhau theo ==. Sửa bằng Equal:

if t1.Equal(t2) {
	fmt.Println("cùng thời điểm")
} else {
	fmt.Println("khác")
}

Bài 3 (vận dụng — code Go). Viết đoạn mã Go minh hoạ chênh lệch giữa AddDate(0,0,1) và Add(24*time.Hour) khi cộng "một ngày" qua thời điểm chuyển sang giờ mùa hè ở London (giả sử có sẵn location Europe/London), và giải thích khi nào nên dùng cái nào.

Đáp án
london, _ := time.LoadLocation("Europe/London")
goc := time.Date(2026, 3, 28, 12, 0, 0, 0, london)

theoLich := goc.AddDate(0, 0, 1)      // 29/03 12:00, cùng "giờ trong ngày"
theoTuyetDoi := goc.Add(24 * time.Hour) // 29/03 13:00, lệch 1 giờ vì ngày 29/03 chỉ có 23 giờ

fmt.Println(theoLich, theoTuyetDoi)

AddDate làm việc theo lịch dân dụng — phù hợp cho "nhắc nhở mỗi ngày lúc 8 giờ sáng", giữ nguyên giờ-trong-ngày bất kể giờ mùa hè. Add(24*time.Hour) làm việc theo thời gian tuyệt đối (đúng 86400 giây) — phù hợp cho "token hết hạn sau đúng 24 giờ". Chọn theo ý định của bài toán, không theo thói quen.

Bài 4 (bẫy/đánh đổi). Đoạn mã sau đo thời gian một thao tác chạy bao lâu bằng cách lưu Time bắt đầu, serialize qua JSON để lưu vào hàng đợi, rồi sau đó deserialize lại để tính khoảng thời gian đã trôi qua. Giải thích tại sao cách làm này có thể sai, và cách đúng theo bài viết.

t0 := time.Now()
j, _ := json.Marshal(t0)
// ... gửi j qua hàng đợi, một tiến trình khác nhận lại ...
var t0Lai time.Time
json.Unmarshal(j, &t0Lai)
elapsed := time.Since(t0Lai)
Đáp án

Khi Time đi qua serialize (JSON), phần monotonic clock bị mất — chỉ còn lại wall clock. time.Since (và Sub, Until) được thiết kế để dùng phần monotonic nhằm đảm bảo khoảng thời gian đo được không bao giờ âm dù đồng hồ hệ thống bị NTP hoặc người dùng chỉnh lại. Một khi Time đã qua JSON, time.Since trên nó chỉ còn dựa vào wall clock — nếu đồng hồ hệ thống bị lùi lại giữa lúc Marshal và lúc gọi Since, kết quả có thể ra số âm hoặc sai lệch. Bài viết khuyên: đừng đo khoảng thời gian trôi qua bằng một Time đã đi qua JSON hay bất kỳ hình thức serialize nào — chỉ nên đo trực tiếp trong cùng một tiến trình đang chạy, giữ nguyên giá trị time.Time gốc chưa qua biên serialize.

Bài 5 (đọc hiểu/vận dụng thực tế). Một ứng dụng chạy trong container Docker dựa trên ảnh scratch (tối giản, không có hệ điều hành đầy đủ) cần gọi time.LoadLocation("Asia/Ho_Chi_Minh"). Nêu hai cách khắc phục lỗi "unknown time zone" mà bài viết đề cập, và ưu điểm của mỗi cách.

Đáp án

Cách 1: Cài gói tzdata vào ảnh Docker — giữ binary nhỏ nhưng phụ thuộc vào hệ điều hành của container có cài đặt cơ sở dữ liệu múi giờ.

Cách 2: Nhúng trực tiếp cơ sở dữ liệu múi giờ vào binary bằng:

import _ "time/tzdata"

Cách này thêm khoảng 450KB vào kích thước binary nhưng loại bỏ hoàn toàn phụ thuộc vào hệ điều hành — rất phù hợp cho ảnh scratch tối giản không có gì khác ngoài chính binary. Bài viết cũng nhắc thêm: nhớ đặt biến môi trường TZ trong container, vì mặc định là UTC — thiếu bước này thì một cron job chạy "6 giờ sáng giờ Việt Nam" sẽ chạy sai giờ do máy chủ nghĩ đang là 23 giờ hôm trước.