Series Go Backend: Maps - Cấu trúc dữ liệu key-value và lưu ý khi sử dụng
Nếu Slice là cấu trúc dữ liệu dạng tuần tự (Index-based) thì Map là cấu trúc dữ liệu dạng ánh xạ (Key-Value), tương tự như Dictionary trong C# hay Associative Array trong PHP.
Bên dưới tầng low-level, Map trong Go được triển khai bằng cấu trúc Hash Table, cho phép thao tác tìm kiếm (Look up), thêm mới, và xóa phần tử cực kỳ nhanh chóng với độ phức tạp trung bình là O(1).
1. Khai báo và sự nguy hiểm của "Nil Map"
Cũng giống như Slice, Zero Value của một Map chưa khởi tạo là nil. Tuy nhiên, thao tác trên một "Nil Map" sẽ gây ra hậu quả nghiêm trọng.
Go
// Khai báo nhưng chưa khởi tạo -> Đây là một nil map
var userAges map[string]int
// LỖI CHẾT NGƯỜI (Panic): Bạn không thể gán giá trị vào một nil map
// userAges["Alice"] = 25
Để sử dụng được, bạn bắt buộc phải cấp phát bộ nhớ cho Map. Có hai cách chuẩn để khởi tạo:
Go
// Cách 1: Sử dụng make() - Khuyên dùng khi khai báo map rỗng
// Nếu biết trước số lượng phần tử, bạn có thể truyền thêm tham số capacity: make(map[string]int, 100)
scores := make(map[string]int)
scores["Math"] = 90
// Cách 2: Khởi tạo bằng Literal - Dùng khi đã có sẵn dữ liệu
configs := map[string]string{
"db_host": "localhost",
"db_port": "3306",
}
2. Thao tác CRUD và kỹ thuật "Comma OK"
Thêm, Sửa và Xóa:
Go
inventory := make(map[string]int)
inventory["laptop"] = 10 // Thêm mới
inventory["laptop"] = 15 // Cập nhật (Sửa)
delete(inventory, "laptop") // Xóa phần tử bằng hàm built-in delete()
Đọc dữ liệu và bẫy Zero Value: Khi bạn truy xuất một Key không tồn tại trong Map, Go sẽ không báo lỗi, mà lẳng lặng trả về Zero Value của ValueType đó.
Go
balances := map[string]int{"Alice": 100}
fmt.Println(balances["Bob"]) // In ra: 0 (vì Bob không tồn tại, int trả về 0)
Tuy nhiên, trong thực tế, giá trị 0 này rất mơ hồ. 0 là do tài khoản Bob hết tiền, hay 0 là do hệ thống chưa từng có dữ liệu của Bob? Để phân biệt, Go cung cấp kỹ thuật "Comma OK" (hay còn gọi là idiom value, ok).
Go
// ok sẽ là true nếu Key tồn tại, false nếu Key không tồn tại
value, ok := balances["Bob"]
if !ok {
fmt.Println("Không tìm thấy dữ liệu của Bob trong hệ thống")
} else {
fmt.Printf("Số dư của Bob là: %d\n", value)
}
3. Thứ tự lặp (Iteration Order) là hoàn toàn ngẫu nhiên
Bạn có thể duyệt qua một Map bằng vòng lặp for range. Tuy nhiên, một đặc điểm "kỳ lạ nhưng cố ý" của Go là: Thứ tự lặp qua các phần tử của Map sẽ bị xáo trộn ngẫu nhiên mỗi lần chạy.
Go
fruits := map[string]int{"Apple": 1, "Banana": 2, "Cherry": 3}
for key, val := range fruits {
fmt.Printf("%s: %d | ", key, val)
}
// Chạy lần 1 có thể ra: Apple: 1 | Banana: 2 | Cherry: 3
// Chạy lần 2 có thể ra: Cherry: 3 | Apple: 1 | Banana: 2
Lý do: Các kỹ sư tạo ra Go nhận thấy lập trình viên thường vô tình viết code dựa dẫm vào thứ tự sinh ra từ hàm băm (hash function). Khi phiên bản Go thay đổi thuật toán băm, code của họ bị vỡ. Do đó, Go chủ động xáo trộn thứ tự lặp để ép lập trình viên hiểu rằng: Map không sinh ra để lưu trữ thứ tự, nếu cần thứ tự, hãy dùng Slice.
4. Map KHÔNG Thread-Safe (Lưu ý sống còn cho Backend)
Trong các hệ thống Backend xử lý đồng thời (Concurrency) bằng Goroutines, nhiều request có thể cùng chia sẻ một biến Map (ví dụ: Map lưu Cache).
Nếu có nhiều Goroutine cùng ghi (write) hoặc một Goroutine ghi và một Goroutine đọc (read) trên cùng một biến Map tại cùng một thời điểm, Go sẽ văng lỗi Fatal Error: concurrent map writes và sập toàn bộ ứng dụng (Panic này không thể recover được).
Giải pháp (sẽ học kỹ ở phần Concurrency):
-
Sử dụng
sync.RWMutexđể khóa Map lại khi có thao tác đọc/ghi. -
Sử dụng
sync.Map(một biến thể Map đặc biệt an toàn cho môi trường đa luồng do thư viện chuẩn của Go cung cấp).
All rights reserved