Sự Cố
2 giờ sáng và batch job của bạn vừa chết với lỗi này:
java.lang.IllegalStateException: Duplicate key userId=1042
at java.util.stream.Collectors.duplicateKeyException(Collectors.java:133)
at java.util.stream.Collectors.lambda$toMap$1(Collectors.java:174)
at java.util.HashMap.merge(HashMap.java:1262)
at java.util.stream.Collectors.lambda$toMap$2(Collectors.java:174)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195)
at com.example.UserService.buildUserMap(UserService.java:58)
Đoạn code gây ra lỗi trông có vẻ vô hại:
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, u -> u));
Chạy tốt trên dev. Chạy tốt trên staging. Nhưng lại lỗi trên prod lúc 2 giờ sáng vì dữ liệu prod có các bản ghi trùng lặp mà test dataset của bạn không có.
Nguyên Nhân
Collectors.toMap() dạng hai tham số hoàn toàn không chấp nhận khóa trùng lặp. Ngay khi hai phần tử ánh xạ tới cùng một khóa, nó ném IllegalStateException và dừng lại. Không có kiểu "cái sau ghi đè". Không có ghi đè ngầm. Chỉ là crash.
Lỗi đầy đủ từ production trông như sau:
java.lang.IllegalStateException: Duplicate key (attempt to merge values key1 and key2)
Ba nguyên nhân phổ biến:
- Database trả về nhiều dòng cho một trường bạn cho là unique — constraint đã lỗi thời, soft delete, hoặc migration dữ liệu bị lỗi
- Một danh sách được tổng hợp từ nhiều nguồn và ghép lại mà không loại bỏ trùng lặp
- Hàm tạo khóa không unique như bạn nghĩ (ví dụ một trường có thể null sẽ gom tất cả các null về một khóa duy nhất)
Bước 1 — Tìm Bản Ghi Trùng Lặp
Trước khi sửa code, hãy xác nhận xem cái gì đang bị trùng. Thêm đoạn chẩn đoán này:
// Tìm các khóa xuất hiện nhiều hơn một lần
Map<Long, Long> idCount = users.stream()
.collect(Collectors.groupingBy(User::getId, Collectors.counting()));
idCount.entrySet().stream()
.filter(e -> e.getValue() > 1)
.forEach(e -> System.err.println("Duplicate id: " + e.getKey() + " count: " + e.getValue()));
Đoạn này cho bạn biết những khóa nào bị trùng và bao nhiêu lần. Chạy một lần với snapshot dữ liệu production của bạn. Có thể bạn tìm ra 3 bản trùng. Có thể là 3.000. Phạm vi đó sẽ quyết định cách sửa nào phù hợp.
Cách Sửa 1 — Merge Function (Phổ Biến Nhất)
Truyền tham số thứ ba vào toMap() — merge function — để định nghĩa điều gì xảy ra khi hai phần tử có cùng khóa.
Giữ phần tử cuối (ý định phổ biến nhất)
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(
User::getId,
u -> u,
(existing, replacement) -> replacement // cái sau thắng
));
Giữ phần tử đầu tiên
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(
User::getId,
u -> u,
(existing, replacement) -> existing // cái trước thắng
));
Gộp giá trị khi bản trùng là có chủ ý
// Ví dụ: cộng điểm cho cùng một userId
Map<Long, Integer> scoreMap = records.stream()
.collect(Collectors.toMap(
Record::getUserId,
Record::getScore,
Integer::sum
));
Chọn chiến lược phù hợp với yêu cầu nghiệp vụ của bạn. "Cái sau thắng" ổn khi bạn đang xây dựng bảng tra cứu và các bản trùng là vấn đề chất lượng dữ liệu. Khi các bản trùng mang dữ liệu có ý nghĩa — ví dụ hai giao dịch cho cùng một đơn hàng — bạn cần một merge thực sự.
Cách Sửa 2 — Gom Thành Danh Sách Giá Trị
Đôi khi bạn thực sự cần tất cả các giá trị theo khóa, không phải chỉ một. Đừng dùng toMap() ở đây — hãy dùng groupingBy():
// Map<userId, List<User>>
Map<Long, List<User>> grouped = users.stream()
.collect(Collectors.groupingBy(User::getId));
// Map<userId, count>
Map<Long, Long> counts = users.stream()
.collect(Collectors.groupingBy(User::getId, Collectors.counting()));
Trường hợp điển hình: ánh xạ ID đơn hàng tới các dòng sản phẩm của nó. Các bản trùng không phải là lỗi — đó là mô hình dữ liệu. groupingBy() được thiết kế chính xác cho trường hợp này.
Cách Sửa 3 — Loại Bỏ Trùng Lặp Kèm Ghi Log
Khi các bản trùng là dữ liệu xấu và bạn muốn service tiếp tục chạy đồng thời để lại dấu vết:
Map<Long, User> userMap = users.stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(
User::getId,
u -> u,
(a, b) -> {
log.warn("Duplicate user id {}, dropping: {}", a.getId(), b);
return a;
}
),
Collections::unmodifiableMap
));
Service tiếp tục chạy. Warning log cung cấp cho người phụ trách vấn đề chất lượng dữ liệu thứ gì đó cụ thể để xử lý — một ID, một số lần đếm, một timestamp.
Cách Sửa 4 — Chỉ Định Kiểu Map Cụ Thể
Tham số thứ tư cho phép bạn kiểm soát kiểu map đầu ra. Hữu ích khi thứ tự quan trọng:
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(
User::getId,
u -> u,
(existing, replacement) -> replacement,
LinkedHashMap::new // giữ nguyên thứ tự chèn
));
Dùng LinkedHashMap::new để giữ thứ tự chèn, TreeMap::new cho khóa được sắp xếp. Không có tham số này, bạn sẽ nhận được HashMap thông thường không đảm bảo thứ tự.
Kiểm Tra Sau Khi Sửa
Sau khi áp dụng merge function, hãy viết test cố tình đưa vào dữ liệu trùng lặp:
@Test
void toMap_withDuplicates_shouldKeepLast() {
List<User> users = List.of(
new User(1L, "Alice"),
new User(1L, "Alice-updated"), // id trùng lặp
new User(2L, "Bob")
);
Map<Long, User> result = users.stream()
.collect(Collectors.toMap(
User::getId,
u -> u,
(existing, replacement) -> replacement
));
assertEquals(2, result.size());
assertEquals("Alice-updated", result.get(1L).getName());
assertEquals("Bob", result.get(2L).getName());
}
Đồng thời truy ngược nguồn gốc các bản trùng. Nếu dữ liệu đến từ một câu truy vấn database, hãy kiểm tra xem SQL có vô tình tạo ra cross-join hay tầng repository đang trả về các dòng chưa được loại trùng không.
Tóm Tắt Nhanh
toMap()hai tham số — ném lỗi khi gặp khóa trùng, không có ngoại lệtoMap()ba tham số — merge function xử lý trùng lặp; dùng cái này trong productiongroupingBy()— khi nhiều giá trị trên một khóa là mô hình đúng- Loại trùng kèm ghi log — khi bản trùng là dữ liệu xấu và bạn muốn có bằng chứng
Bài Học Thực Sự
Collectors.toMap() hai tham số là một quả mìn trong code production. Dev và staging có dữ liệu test sạch, được tạo thủ công. Production có năm năm import, chạy lại, migration lỗi, và các bản ghi không ai còn nhớ đã tạo ra.
Hãy biến đây thành quy ước của team: bất kỳ toMap() nào chạm vào dữ liệu mà bạn không kiểm soát hoàn toàn đều phải có merge function. Phiên bản ba tham số không chậm hơn hay khó đọc hơn. Nó chỉ buộc bạn phải rõ ràng về những gì code của bạn làm khi thực tế không khớp với giả định của bạn — mà trong production, điều đó sẽ xảy ra.

