Basics 26 và 27 lưu catalogue trong PostgreSQL qua Spring Data JPA: một @Entity, một JpaRepository, derived query method và @Query. Spring Data có cùng mô hình repository cho các store không quan hệ, và hai cái tên xuất hiện trong hầu hết các codebase Spring Boot: MongoDB, nơi dữ liệu là các document dạng JSON, và Redis, một key-value store chạy trong bộ nhớ với value là các cấu trúc dữ liệu. Interface trông quen thuộc. Còn thứ được lưu, write nào là atomic, field nào có index và transaction nghĩa là gì thì khác hẳn, và sự khác biệt chỉ lộ ra khi nhìn vào những gì thật sự đến server.
Bài này đưa cả hai vào cùng một ứng dụng catalogue và cho thấy command mà mỗi lời gọi gửi đi. Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với MongoDB 8 và Redis 8, ứng dụng chạy ở port 8211. Thời gian đo có kèm load average một phút bên cạnh mỗi số: chỉ để tham khảo, không phải benchmark.
![]()
Nửa đầu là MongoDB, từ việc map một document đến transaction và schema bị lệch; nửa sau là Redis dùng làm nơi lưu dữ liệu, không phải cache, vốn là chủ đề của bài 9.
Project: một ứng dụng Spring Boot, MongoDB và Redis
Ứng dụng vẫn là catalogue sản phẩm. Product nằm trong MongoDB, với attribute riêng theo category và review embedded; bộ đếm lượt xem, danh sách bán chạy, giỏ hàng, mã dùng một lần và việc giữ hàng nằm trong Redis. Initializr tạo project với cả hai starter của Spring Data:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-mongodb,data-redis,validation,actuator" -o demo.zipdependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
testImplementation 'org.springframework.boot:spring-boot-starter-data-mongodb-test'
testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}spring-boot-starter-data-mongodb hay spring-boot-starter-mongodb?
BOM của Boot 4.1.1 có cả hai, và ./gradlew dependencies cho thấy chúng lồng nhau thế nào:
+--- org.springframework.boot:spring-boot-starter-data-mongodb -> 4.1.1
| +--- org.springframework.boot:spring-boot-starter-mongodb:4.1.1
| | +--- org.springframework.boot:spring-boot-starter:4.1.1 (*)
| | +--- org.springframework.boot:spring-boot-mongodb:4.1.1
| | \--- org.mongodb:mongodb-driver-sync:5.8.1
| \--- org.springframework.boot:spring-boot-data-mongodb:4.1.1
| ...
| \--- org.springframework.data:spring-data-mongodb:5.1.1spring-boot-starter-mongodb chỉ có driver: mongodb-driver-sync cộng auto-configuration MongoClient của Boot, dành cho code dùng thẳng API của driver. spring-boot-starter-data-mongodb thêm Spring Data MongoDB lên trên: MongoTemplate, phần mapping và repository, là thứ bài này dùng. Việc tách module cũng chuyển chỗ các property kết nối: spring.mongodb.uri, spring.mongodb.host và các property cùng nhóm thuộc về module driver trong 4.1.1, còn các tên cũ như spring.data.mongodb.uri được metadata đánh dấu deprecated, với tên mới làm replacement. Những setting chỉ Spring Data đọc, như spring.data.mongodb.auto-index-creation, vẫn giữ tiền tố spring.data. Redis không bị tách: property của nó vẫn nằm dưới spring.data.redis.
Khi có hai module Spring Data trên classpath, việc scan repository chuyển sang strict mode và gán mỗi repository theo annotation trên entity (@Document cho MongoDB, @RedisHash cho Redis) hoặc theo interface gốc:
Multiple Spring Data modules found, entering strict repository configuration mode
Bootstrapping Spring Data MongoDB repositories in DEFAULT mode.
Spring Data MongoDB - Could not safely identify store assignment for repository candidate interface com.example.demo.reservation.ReservationRepository; If you want this repository to be a MongoDB repository, consider annotating your entities with one of these annotations: org.springframework.data.mongodb.core.mapping.Document (preferred), or consider extending one of the following types with your repository: org.springframework.data.mongodb.repository.MongoRepository
Finished Spring Data repository scanning in 60 ms. Found 3 MongoDB repository interfaces.
Multiple Spring Data modules found, entering strict repository configuration mode
Bootstrapping Spring Data Redis repositories in DEFAULT mode.
Finished Spring Data repository scanning in 3 ms. Found 1 Redis repository interface.Module Redis cũng in loại dòng này cho từng repository trong ba repository MongoDB, đã được lược bớt ở đây. Đó là các dòng INFO vô hại: mỗi module báo lại những repository nó để lại cho module kia.
Chạy MongoDB 8 dạng replica set một node, và Redis 8
Transaction của MongoDB cần replica set, nên container khởi động ngay từ đầu như một replica set một thành viên; một thành viên là đủ cho môi trường dev:
docker run -d --name sba-a11-mongo --memory 1g -p 27111:27017 mongo:8 --replSet rs0 --wiredTigerCacheSizeGB 0.25docker exec sba-a11-mongo mongosh --quiet --eval 'rs.initiate({_id: "rs0", members: [{_id: 0, host: "localhost:27017"}]})'docker run -d --name sba-a11-redis --memory 512m -p 6311:6379 redis:8rs.initiate trả về { ok: 1, … }; db.version() báo 8.3.11, mongosh --version báo 2.11.1, còn redis-cli INFO server cho redis_version:8.10.1. Cấu hình:
spring.application.name=demo
server.port=8211
spring.mongodb.uri=mongodb://localhost:27111/catalog?directConnection=true
spring.data.redis.host=localhost
spring.data.redis.port=6311
logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUGspring:
application:
name: demo
mongodb:
uri: mongodb://localhost:27111/catalog?directConnection=true
data:
redis:
host: localhost
port: 6311
server:
port: 8211
logging:
level:
org.springframework.data.mongodb.core.MongoTemplate: DEBUGThành viên của replica set tự công bố địa chỉ là localhost:27017, tức port bên trong container. directConnection=true bảo driver dùng đúng địa chỉ trong URI và không chuyển sang địa chỉ được công bố. Việc chuyển đó là có thật: khi URI dùng ?replicaSet=rs0 thay vào, driver phát hiện replica set, bỏ port 27111 và bỏ cuộc sau khi hết thời gian chọn server:
org.springframework.dao.DataAccessResourceFailureException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=REPLICA_SET, servers=[{address=localhost:27017, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]Khi không có option nào, driver 5.8.1 đã tự kết nối trực tiếp nếu URI chỉ có một host (mode=SINGLE trong log khởi động), nên directConnection=true chủ yếu để ghi rõ ý định. Mức DEBUG của MongoTemplate log mọi query mà MongoTemplate dựng; để thấy đúng command gửi qua mạng, các lần chạy bên dưới còn truyền thêm --logging.level.org.mongodb.driver.protocol.command=DEBUG, tức command log của driver.
Mỗi thí nghiệm là một ApplicationRunner trong profile lab, chạy từ file jar kèm tên thí nghiệm:
java -Xmx512m -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=lab --lab=queries --spring.main.web-application-type=noneKhi nào document hợp hơn bảng quan hệ?
Một catalogue bán bàn phím, màn hình và tai nghe có attribute tuỳ theo category: bàn phím có layout và loại switch, màn hình có tấm nền và độ phân giải, tai nghe có thời lượng pin. Trong PostgreSQL, đó là lựa chọn giữa một bảng rộng đầy giá trị null, một bảng attribute mỗi dòng một cặp tên và giá trị, hoặc một cột jsonb. Trong MongoDB, chúng đơn giản là một phần của document, cùng với review, thứ luôn được đọc kèm product và không bao giờ đọc riêng:

Đó là hình dạng mà document làm tốt: một aggregate được đọc và ghi như một khối, các phần của nó không tồn tại độc lập, và field thay đổi từ document này sang document khác. Một lần đọc trả về cả trang product, một lần ghi cập nhật nó một cách atomic.
Document không hợp khi dữ liệu là một đồ thị chứ không phải một cây. Order trỏ tới product và customer, tồn kho được chia sẻ giữa các order, báo cáo join mọi thứ, và nhiều document phải thay đổi cùng nhau. MongoDB làm được tất cả, bằng reference, $lookup và transaction nhiều document, nhưng mỗi thứ đó lại trả bớt đi chính cái lý do chọn mô hình document. Brand trong bài này là phiên bản nhỏ của vấn đề đó: nhiều product dùng chung một brand, nên brand được lưu dạng reference chứ không embedded, và phần "Embedded hay reference" bên dưới đo xem cái giá là bao nhiêu.
Map một product: @Document, @Id, @Field và field _class
Review embedded là một record; Spring Data MongoDB dựng record qua canonical constructor:
package com.example.demo.product;
import java.time.Instant;
public record Review(String author, int rating, String comment, Instant createdAt) {
}Brand là một document riêng, trong collection riêng:
package com.example.demo.brand;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Document("brands")
public record Brand(@Id String id, String name, String country) {
}Product:
package com.example.demo.product;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.DocumentReference;
import org.springframework.data.mongodb.core.mapping.Field;
import com.example.demo.brand.Brand;
@Document("products")
@CompoundIndex(name = "category_price", def = "{ 'category': 1, 'price': 1 }")
public class Product {
@Id
private String id;
@Indexed(unique = true)
private String sku;
private String name;
private String description;
private String category;
private BigDecimal price;
@Field("qty")
private int stock;
@DocumentReference
private Brand brand;
private Map<String, Object> attributes = new LinkedHashMap<>();
private List<Review> reviews = new ArrayList<>();
private int reviewCount;
protected Product() {
}
public Product(String sku, String name, String category, BigDecimal price, int stock, Brand brand) {
this.sku = sku;
this.name = name;
this.category = category;
this.price = price;
this.stock = stock;
this.brand = brand;
}
public void addReview(Review review) {
reviews.add(review);
reviewCount++;
}
// getters for every field; setters for description, price and stock
}@Document("products") đặt tên collection; một @Document trống sẽ lấy tên từ class, viết thường chữ cái đầu (record thử nghiệm StockAlert được map vào stockAlert). @Field("qty") lưu stock dưới tên khác, và mọi query nhắc đến stock trong Java đều được dịch thành qty. @Indexed và @CompoundIndex khai báo index, thứ mà Boot mặc định không tạo (xem phần index). Không có schema nào để tạo: collection xuất hiện cùng lần insert đầu tiên. Runner seed lưu bảy brand và tám product, bắt đầu bằng product này:
Product k2 = new Product("KB-K2-BRN", "Keychron K2 Wireless", "keyboard", new BigDecimal("89.00"), 25, keychron);
attributes(k2, "layout", "75%", "switchType", "brown", "wireless", true);
k2.setDescription("Compact wireless mechanical keyboard");
k2.addReview(review("minh", 5, "Great switches"));
k2.addReview(review("lan", 4, "Battery could be better"));
k2.addReview(review("tuan", 5, "Perfect for Mac"));mongosh cho thấy thứ đã được lưu:
docker exec sba-a11-mongo mongosh catalog --quiet --eval 'printjson(db.products.findOne({sku: "KB-K2-BRN"}))'{
_id: ObjectId('6aace449f1341b7637fcb0d4'),
sku: 'KB-K2-BRN',
name: 'Keychron K2 Wireless',
description: 'Compact wireless mechanical keyboard',
category: 'keyboard',
price: Decimal128('89.00'),
qty: 25,
brand: ObjectId('6aace448f1341b7637fcb0cd'),
attributes: {
layout: '75%',
switchType: 'brown',
wireless: true
},
reviews: [
{
author: 'minh',
rating: 5,
comment: 'Great switches',
createdAt: ISODate('2026-09-18T07:00:00.000Z')
},
{
author: 'lan',
rating: 4,
comment: 'Battery could be better',
createdAt: ISODate('2026-09-18T07:00:00.000Z')
},
{ … }
],
reviewCount: 3,
_class: 'com.example.demo.product.Product'
}Bốn chi tiết. BigDecimal thành Decimal128, một type decimal chính xác, mà không cần annotation nào; một document thử nghiệm chỉ chứa một BigDecimal cũng lưu {"$numberDecimal": "89.00"}, nên trên stack này mặc định là chính xác. Field stock thành qty. Brand chỉ còn ObjectId của nó. Và có một field _class mà không property Java nào yêu cầu.
Id là String hay ObjectId: thứ gì được lưu?
Product.id là String, nhưng _id lại là ObjectId. Spring Data đổi id String sang ObjectId mỗi khi giá trị là chuỗi hex 24 ký tự hợp lệ, và tự sinh một cái khi id là null. Một thử nghiệm lưu ba document vào collection probes với id String: để null, gán một SKU, và gán 24 ký tự hex, rồi đọc lại qua driver:
{"_id": {"$oid": "6aace4692b01124767e79b4c"}, "note": "id left null", "_class": "com.example.demo.lab.MappingProbeLab$IdProbe"}
{"_id": "sku-KB-K2-BRN", "note": "id set to a plain string", "_class": "com.example.demo.lab.MappingProbeLab$IdProbe"}
{"_id": {"$oid": "6aace4692b01124767e79b4d"}, "note": "id set to 24 hex characters", "_class": "com.example.demo.lab.MappingProbeLab$IdProbe"}Id được sinh trả về Java dưới dạng chuỗi 6aace4692b01124767e79b4c. Vậy id String tiện trong Java và trong JSON response, còn database vẫn giữ ObjectId 12 byte, thứ còn mang cả giây tạo ra nó. Cái bẫy nằm ở dữ liệu do chương trình khác ghi. Một brand được insert qua driver với chuỗi "6aace469aaaaaaaaaaaaaaaa" làm _id không được BrandRepository.findById("6aace469aaaaaaaaaaaaaaaa") tìm thấy, kết quả là Optional.empty: 24 ký tự hex bị đổi sang ObjectId trước khi query, và một string không bao giờ bằng một ObjectId. Một record map vào cùng collection với @MongoId(FieldType.STRING) String id thì tìm được. Khi id đến từ bên ngoài, hãy cố định type được lưu bằng @MongoId(FieldType.STRING) hoặc @MongoId(FieldType.OBJECT_ID).
Field _class dùng để làm gì?
_class ghi lại type Java đã ghi document, để có thể dựng lại đúng type khi type khai báo không đủ thông tin. Với một Product đọc thành Product thì nó thừa, nên review embedded không có _class: runtime type của chúng trùng với type khai báo. Nó quan trọng khi có đa hình. Thử nghiệm lưu một List<Discount>, trong đó Discount là sealed interface có hai record implement, một trong hai có @TypeAlias("fixed"):
{"_id": "promo", "discounts": [{"percent": 10, "_class": "com.example.demo.lab.MappingProbeLab$PercentOff"}, {"amount": {"$numberDecimal": "5.00"}, "_class": "fixed"}], "_class": "com.example.demo.lab.MappingProbeLab$Promo"}Khi xoá _class khỏi discount đầu tiên ($unset trên discounts.0._class), việc đọc document thất bại:
org.springframework.data.mapping.model.MappingInstantiationException: Failed to instantiate com.example.demo.lab.MappingProbeLab$Discount using constructor NO_CONSTRUCTOR with arguments Đó là lý do không nên xoá _class một cách mù quáng, và là lý do nên dùng @TypeAlias cho mọi thứ đa hình: một tên class đầy đủ nằm trong dữ liệu sẽ hỏng vào ngày class bị đổi tên hoặc chuyển sang package khác.
Embedded hay reference: mỗi cách gửi những query nào
Review là embedded, nên đọc một product trả về luôn review trong cùng một reply. Brand là một reference qua @DocumentReference, cách này lưu id của brand và nạp brand khi đọc product. Khi bật command log của driver, productRepository.findAll() trên tám product đã gửi những command sau (các field lsid, $clusterTime và $readPreference được cắt khỏi mỗi command):
{"find": "products", "filter": {}, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0cd"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0cd"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0ce"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0cf"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0d0"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0d1"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace449f1341b7637fcb0d2"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$oid": "6aace449f1341b7637fcb0d3"}}, "limit": 1, "singleBatch": true, "$db": "catalog"}Chín round trip cho tám product: mỗi product một lần, không hề gộp trùng (cả hai bàn phím Keychron đều lấy brand …cd). Đây là vấn đề N+1 của JPA dưới hình dạng mới, và nó nhân lên với mọi query trả về product.
Cách còn lại là reference thủ công: đọc id thô rồi nạp mọi brand bằng một $in. Một record projection có thể đọc field brand đã lưu dưới dạng ObjectId:
package com.example.demo.product;
import java.math.BigDecimal;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Field;
public record ProductListItem(String id, String name, BigDecimal price, @Field("brand") ObjectId brandId) {
}List<ProductListItem> items = products.findAllBy(ProductListItem.class);
Set<String> brandIds = items.stream().map(i -> i.brandId().toHexString()).collect(Collectors.toSet());
Map<String, Brand> byId = brands.findAllById(brandIds).stream()
.collect(Collectors.toMap(Brand::id, Function.identity()));{"find": "products", "filter": {}, "projection": {"price": 1, "brand": 1, "name": 1, "_id": 1}, "$db": "catalog"}
{"find": "brands", "filter": {"_id": {"$in": [{"$oid": "6aace449f1341b7637fcb0d3"}, {"$oid": "6aace449f1341b7637fcb0d2"}, {"$oid": "6aace448f1341b7637fcb0d0"}, {"$oid": "6aace448f1341b7637fcb0d1"}, {"$oid": "6aace448f1341b7637fcb0ce"}, {"$oid": "6aace448f1341b7637fcb0cd"}, {"$oid": "6aace448f1341b7637fcb0cf"}]}}, "$db": "catalog"}Hai round trip, bất kể có bao nhiêu product, và projection chỉ đọc bốn field. Quy tắc giống như với JPA: embed thứ thuộc về cha và có giới hạn, dùng reference cho thứ được dùng chung, rồi nạp reference hàng loạt ở chỗ đọc danh sách. Review được embed ở đây vì mỗi product chỉ có vài cái; review tăng không giới hạn thì nên nằm trong collection riêng, vì một document bị giới hạn 16 MB (con số maxDocumentSize=16777216 driver log lúc khởi động) và mỗi lần đọc product sẽ phải mang theo tất cả.
MongoRepository: derived query và @Query, kèm command chúng gửi
Repository extend MongoRepository thay cho JpaRepository; quy tắc đặt tên method từ Basics 27 vẫn y nguyên:
package com.example.demo.product;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
public interface ProductRepository extends MongoRepository<Product, String> {
Optional<Product> findBySku(String sku);
List<Product> findByCategoryAndPriceLessThan(String category, BigDecimal maxPrice, Sort sort);
List<Product> findByNameContainingIgnoreCase(String text);
List<Product> findByStockLessThan(int threshold);
List<Product> findByReviewsRatingGreaterThanEqual(int rating);
List<Product> findByDescriptionContaining(String text);
@Query("{ 'category': ?0, 'attributes.switchType': ?1 }")
List<Product> findKeyboardsBySwitch(String category, String switchType);
@Query(value = "{ 'reviewCount': { '$gte': ?0 } }", fields = "{ 'sku': 1, 'name': 1, 'reviewCount': 1 }")
List<Product> findWellReviewed(int minReviews);
<T> List<T> findByCategory(String category, Class<T> type);
<T> List<T> findAllBy(Class<T> type);
}Kết quả của từng method, với filter chép từ dòng DEBUG của MongoTemplate:
| Lời gọi method | Filter được gửi | Kết quả |
|---|---|---|
findBySku("KB-K2-BRN") | { "sku" : "KB-K2-BRN"}, limit: 2 | chiếc K2 |
findByCategoryAndPriceLessThan("headphones", 350, Sort.by("price")) | { "category" : "headphones", "price" : { "$lt" : { "$numberDecimal" : "350"}}}, sort { "price" : 1} | HP-MOMENTUM4 299.95, HP-WH1000XM5 329.00 |
findByNameContainingIgnoreCase("keychron") | { "name" : { "$regularExpression" : { "pattern" : ".*keychron.*", "options" : "i"}}} | cả hai bàn phím Keychron |
findByStockLessThan(5) | { "qty" : { "$lt" : 5}} | KB-G915-TKL 4, MN-27GP850 0, HP-QC-ULTRA 3 |
findByReviewsRatingGreaterThanEqual(5) | { "reviews.rating" : { "$gte" : 5}} | 5 product có ít nhất một review 5 sao |
findKeyboardsBySwitch("keyboard", "brown") | { "category" : "keyboard", "attributes.switchType" : "brown"} | KB-K2-BRN, KB-G915-TKL |
findWellReviewed(3) | { "reviewCount" : { "$gte" : 3}}, projection {sku=1, name=1, reviewCount=1} | 3 product thiếu field |
findByCategory("monitor", ProductListItem.class) | { "category" : "monitor"}, projection {price=1, brand=1, name=1, _id=1} | 2 record ProductListItem |
Có bốn điểm riêng của MongoDB trong bảng trên. stock thành qty, nhờ @Field. Một property path đi qua mảng embedded, ReviewsRating, thành đường dẫn có dấu chấm reviews.rating, khớp với một document khi bất kỳ phần tử nào khớp. Containing thành một regular expression không neo, .*keychron.* với option i, phải được thử trên từng giá trị. Và method trả về Optional xin hai document: document thứ hai là cách Spring Data nhận ra kết quả không duy nhất. Khi insert tay thêm một KB-K8-RED thứ hai (lúc chưa có unique index), findBySku("KB-K8-RED") thất bại với:
org.springframework.dao.IncorrectResultSizeDataAccessException: Query { "$java" : Query: { "sku" : "KB-K8-RED"}, Fields: {}, Sort: {} } returned non unique resultCommand log của driver cho thấy cùng query đó dưới dạng command thật sự đi qua mạng, theo sau là lần tra brand của @DocumentReference (đã cắt như trên):
Command "find" started on database "catalog" using a connection with driver-generated ID 3 and server-generated ID 26 to localhost:27111. The request ID is 5 and the operation ID is 5. Command: {"find": "products", "filter": {"sku": "KB-K2-BRN"}, "limit": 2, "$db": "catalog", …}
Command "find" started on database "catalog" … Command: {"find": "brands", "filter": {"_id": {"$oid": "6aace448f1341b7637fcb0cd"}}, "limit": 1, "singleBatch": true, "$db": "catalog", …}Hai cái bẫy lộ ra ở các dòng projection. findWellReviewed trả về entity Product dựng từ ba field: chúng in ra price=null, stock=0, reviews=0, và lưu lại một cái như vậy sẽ ghi đè các giá trị đó lên giá trị thật, nên đừng bao giờ đưa một entity nạp dở dang cho save. Và dù projection đã bỏ brand, Spring Data MongoDB 5.1.1 vẫn gửi một {"find": "brands", "filter": {"_id": {}}, "limit": 1, …} cho mỗi product, ba round trip không khớp gì cả. Record projection ProductListItem không mắc lỗi nào: nó không phải entity để ai đó save, và không gửi lần tra brand nào.
MongoTemplate với Query và Criteria
Derived method hết dễ đọc sau ba điều kiện, và màn hình tìm kiếm thì dựng điều kiện lúc runtime. MongoTemplate nhận một Query dựng từ Criteria, phiên bản MongoDB của Criteria API trong JPA ở bài 8, gọn hơn nhiều:
Query query = new Query(Criteria.where("category").is("headphones")
.and("attributes.anc").is(true)
.and("price").lte(new BigDecimal("350")))
.with(Sort.by(Sort.Direction.DESC, "reviewCount"))
.limit(5);
mongo.find(query, ProductListItem.class, "products").forEach(System.out::println);
Query attention = new Query(new Criteria().orOperator(
Criteria.where("reviews").elemMatch(Criteria.where("rating").lte(2)),
Criteria.where("stock").is(0)));
mongo.find(attention, Product.class).forEach(p -> System.out.println(p.getSku()));
System.out.println(mongo.count(new Query(Criteria.where("attributes.wireless").is(true)), Product.class));{"find": "products", "filter": {"category": "headphones", "attributes.anc": true, "price": {"$lte": {"$numberDecimal": "350"}}}, "sort": {"reviewCount": -1}, "limit": 5, "$db": "catalog"}
ProductListItem[id=6aace449f1341b7637fcb0d9, name=Sony WH-1000XM5, price=329.00, brandId=6aace448f1341b7637fcb0d1]
ProductListItem[id=6aace449f1341b7637fcb0db, name=Sennheiser Momentum 4, price=299.95, brandId=6aace449f1341b7637fcb0d3]
{"find": "products", "filter": {"$or": [{"reviews": {"$elemMatch": {"rating": {"$lte": 2}}}}, {"qty": 0}]}, "$db": "catalog"}
MN-27GP850
{"aggregate": "products", "pipeline": [{"$match": {"attributes.wireless": true}}, {"$group": {"_id": 1, "n": {"$sum": 1}}}], "cursor": {}, "$db": "catalog"}
3Criteria.where("stock") được gửi đi thành qty: query chạy với Product.class, và mapping của nó cho biết tên lưu trữ. elemMatch nghĩa là "một phần tử của mảng thoả tất cả các điều kiện này", điều mà đường dẫn có dấu chấm không làm được. Trong mongosh, {"reviews.rating": 5, "reviews.author": "lan"} đếm được 1 product, chiếc K2, nơi minh cho 5 sao còn lan cho 4; {reviews: {$elemMatch: {rating: 5, author: "lan"}}} đếm được 0. count không phải command count mà là một aggregation với $match và $group, đúng cách countDocuments của driver hoạt động.
Update atomic và load-modify-save khi chạy đồng thời
Thêm review, phiên bản đầu tiên tự nhiên nhất là: nạp product, thêm vào list, lưu lại.
public void addReview(String sku, Review review) {
Product product = products.findBySku(sku).orElseThrow(() -> new ProductNotFoundException(sku));
product.addReview(review);
products.save(product);
UpdateResult result = mongo.updateFirst(
Query.query(Criteria.where("sku").is(sku)),
new Update().push("reviews", review).inc("reviewCount", 1),
Product.class);
if (result.getMatchedCount() == 0) {
throw new ProductNotFoundException(sku);
}
}Hai phiên bản gửi những lệnh ghi rất khác nhau. Profiler của MongoDB (db.setProfilingLevel(2)) ghi lại mỗi phiên bản một lời gọi; in filter, cờ upsert và các key cấp cao nhất của update từ db.system.profile cho ra:
{ q: { _id: ObjectId('6aace449f1341b7637fcb0db') }, upsert: true,
uKeys: [ '_id', 'sku', 'name', 'category', 'price', 'qty', 'brand', 'attributes', 'reviews', 'reviewCount', '_class' ],
nModified: 1, docsExamined: 1 }
{ q: { sku: 'HP-MOMENTUM4' }, upsert: false,
uKeys: [ '$push', '$inc' ],
nModified: 1, docsExamined: 8 }save một entity đã có id sẽ thay cả document bằng bản đang giữ trong bộ nhớ. (docsExamined: 8 là quét hết tám product: chưa có index nào trên sku.) updateFirst chỉ gửi phần thay đổi, $push để nối vào mảng và $inc để cộng vào bộ đếm, và MongoDB áp cả hai lên document hiện tại một cách atomic: update trên một document luôn là atomic, bất kể còn gì khác đang chạy cùng lúc. Sự khác biệt lộ ra khi chạy đồng thời. Một harness nhỏ thả N thread cùng lúc bằng CountDownLatch và đếm exception:
public static Map<String, Integer> run(int threads, IntConsumer task) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
Map<String, Integer> errors = new ConcurrentHashMap<>();
try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
for (int i = 0; i < threads; i++) {
int n = i;
pool.submit(() -> {
try {
start.await();
task.accept(n);
} catch (Exception e) {
errors.merge(e.getClass().getSimpleName(), 1, Integer::sum);
} finally {
done.countDown();
}
});
}
start.countDown();
done.await();
}
return errors;
}Năm mươi thread, mỗi thread thêm một review vào HP-MOMENTUM4, bắt đầu từ không review nào, mỗi cách ba vòng:
load-modify-save round 1: 50 calls, reviews stored=1, reviewCount=1, errors={}
load-modify-save round 2: 50 calls, reviews stored=5, reviewCount=5, errors={}
load-modify-save round 3: 50 calls, reviews stored=5, reviewCount=5, errors={}
atomic $push/$inc round 1: 50 calls, reviews stored=50, reviewCount=50, errors={}
atomic $push/$inc round 2: 50 calls, reviews stored=50, reviewCount=50, errors={}
atomic $push/$inc round 3: 50 calls, reviews stored=50, reviewCount=50, errors={}Load-modify-save giữ lại được 1 đến 5 trong 50 review và không báo lỗi nào: mỗi thread đọc product khi nó có không hoặc vài review, thêm review của mình rồi thay cả document, xoá mất mọi thứ được ghi sau lần đọc của nó. Update atomic giữ đủ 50 lần nào cũng vậy. Bất cứ khi nào thay đổi có thể viết thành một operator trên document hiện tại ($inc, $push, $pull, $addToSet, $set một field, $min/$max), hãy gửi operator.
@Version: optimistic locking trong MongoDB
Khi thay đổi không viết được theo cách đó, điển hình là một form sửa nhiều field mà người dùng đã xem, @Version biến việc mất dữ liệu âm thầm thành một lỗi, như trong JPA:
private int reviewCount;
@Version
private Long version; Thêm nó vào một collection đã có document lại có bẫy riêng. Lần save đầu tiên sau thay đổi thất bại:
org.springframework.dao.DuplicateKeyException: Write operation error on MongoDB server localhost:27111. Write error: WriteError{code=11000, message='E11000 duplicate key error collection: catalog.products index: _id_ dup key: { _id: ObjectId('6aace449f1341b7637fcb0db') }', details={}}.Document được nạp không có field version, nên property là null, và một entity có version null được coi là mới: save gửi một lệnh insert (Inserting Document containing fields: [_id, sku, …, version, _class]) cho một _id đã tồn tại. Các document cũ cần có field này trước; db.products.updateMany({version: {$exists: false}}, {$set: {version: NumberLong(0)}}) trả về matchedCount: 8, modifiedCount: 8 (kèm một cảnh báo deprecated từ mongosh; NumberLong("0") thì không có). Sau đó, hai bản của cùng một màn hình được nạp ở version 0, bản này lưu sau bản kia:
both loaded version 0
Calling update using query: { "_id" : { "$oid" : "6aace449f1341b7637fcb0d7"}, "version" : 0} and update: { "sku" : "MN-U2723QE", …, "price" : { "$numberDecimal" : "549.00"}, …, "version" : 1, … } in collection: products
alice saved, version now 1
Calling update using query: { "_id" : { "$oid" : "6aace449f1341b7637fcb0d7"}, "version" : 0} and update: { "sku" : "MN-U2723QE", …, "price" : { "$numberDecimal" : "579.00"}, "qty" : 17, …, "version" : 1, … } in collection: products
OptimisticLockingFailureException: Cannot save entity 6aace449f1341b7637fcb0d7 with version 1 to collection products; Has it been modified meanwhileLần lưu thứ hai lẽ ra đã đưa giá cũ trở lại; filter của nó trên version: 0 không khớp gì và Spring Data ném OptimisticLockingFailureException, thứ mà controller map sang 409 giống hệt bài 7. Cùng cuộc đua 50 thread khi đã có @Version:
load-modify-save round 1: 50 calls, reviews stored=1, reviewCount=1, errors={OptimisticLockingFailureException=49}
load-modify-save round 2: 50 calls, reviews stored=5, reviewCount=5, errors={OptimisticLockingFailureException=45}
load-modify-save round 3: 50 calls, reviews stored=5, reviewCount=5, errors={OptimisticLockingFailureException=45}
atomic $push/$inc round 1: 50 calls, reviews stored=50, reviewCount=50, errors={}
atomic $push/$inc round 2: 50 calls, reviews stored=50, reviewCount=50, errors={}
atomic $push/$inc round 3: 50 calls, reviews stored=50, reviewCount=50, errors={}Số review được lưu không khá hơn, nhưng mỗi lần ghi bị mất giờ là một exception có thể retry hoặc báo lại. Update atomic không bị ảnh hưởng, vì MongoTemplate tự thêm phần tăng version vào: update được log là { "$push" : { "reviews" : … }, "$inc" : { "reviewCount" : 1, "version" : 1}}, nên một lệnh save đồng thời từ một bản cũ vẫn thất bại.
Index: Spring Boot có tạo index từ @Indexed không?
Không. Sau lần khởi động đầu tiên, với @Indexed(unique = true) trên sku và một @CompoundIndex trên class, collection chỉ có index mà collection nào cũng có:
[ { v: 2, key: { _id: 1 }, name: '_id_' } ]spring.data.mongodb.auto-index-creation không có giá trị mặc định trong metadata của Boot 4.1.1, và mapping context báo isAutoIndexCreation() là false. Khi đặt thành true, Spring Data phân tích mọi class được map lúc khởi động và tạo những gì annotation khai báo:
[
{ v: 2, key: { _id: 1 }, name: '_id_' },
{ v: 2, key: { category: 1, price: 1 }, name: 'category_price' },
{ v: 2, key: { sku: 1 }, name: 'sku', unique: true }
]Unique index mang tên property, sku, chứ không phải sku_1 như shell sẽ đặt. Để auto-creation tắt là một mặc định hợp lý. Tạo index trên một collection lớn lúc ứng dụng khởi động sẽ làm chậm việc khởi động (trên 300.000 document ở phần dưới, thời gian khởi động tăng từ 1,7 s lên 2,5 s), và mọi instance của ứng dụng đều làm việc đó. Một unique index gặp dữ liệu trùng sẵn có sẽ chặn ứng dụng: khi insert tay một KB-K8-RED thứ hai, việc khởi động thất bại lúc tạo bean mongoTemplate:
Caused by: org.springframework.dao.DuplicateKeyException: Write failed with error code 11000 and error message 'Index build failed: fd68fcdd-dcc0-496f-b9ee-1c09dd96876d: Collection catalog.products ( 376762f5-674e-44c9-8243-c915b2577c77 ) :: caused by :: E11000 duplicate key error collection: catalog.products index: sku dup key: { sku: "KB-K8-RED" }'Các lựa chọn khác là chỉ bật nó ở môi trường dev, tạo index tường minh (mongoTemplate.indexOps(Product.class).createIndex(…)) từ một bước migration, hoặc quản lý index bên ngoài ứng dụng. Chọn cách nào thì một annotation @Indexed riêng nó cũng không làm query nhanh lên.
COLLSCAN và IXSCAN trên 300.000 document
Một runner thứ hai insert 300.000 product sinh ngẫu nhiên (10 category, giá ngẫu nhiên từ 5.00 đến 999.99, tối đa ba review mỗi product) theo lô 10.000, mất 4,1 s; collection có 300.008 document, trung bình 389 byte. Hai query, lần đầu chưa có index nào ngoài _id, đo bằng explain("executionStats") trong mongosh, lấy lần tốt nhất trong năm lần sau một lần khởi động:
function stages(p) { const out = []; while (p) { out.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : "")); p = p.inputStage; } return out.join(" <- "); }
function run(label, cursorFn) {
let best = null, ex;
for (let i = 0; i < 6; i++) {
ex = cursorFn().explain("executionStats");
const t = ex.executionStats.executionTimeMillis;
if (i > 0 && (best === null || t < best)) best = t; // first run is warm-up
}
const s = ex.executionStats;
print(label + ": plan " + stages(ex.queryPlanner.winningPlan.queryPlan || ex.queryPlanner.winningPlan) +
" | nReturned " + s.nReturned + " | totalKeysExamined " + s.totalKeysExamined +
" | totalDocsExamined " + s.totalDocsExamined + " | executionTimeMillis best of 5: " + best);
}
run("sku", () => db.products.find({ sku: "GEN-123456" }));
run("category+price", () => db.products.find({ category: "headphones", price: { $lt: NumberDecimal("20") } }).sort({ price: 1 }));docker cp explain.js sba-a11-mongo:/tmp/explain.jsdocker exec sba-a11-mongo mongosh catalog --quiet /tmp/explain.jssku: plan COLLSCAN | nReturned 1 | totalKeysExamined 0 | totalDocsExamined 300008 | executionTimeMillis best of 5: 58
category+price: plan SORT <- COLLSCAN | nReturned 466 | totalKeysExamined 0 | totalDocsExamined 300008 | executionTimeMillis best of 5: 63Load average 4,56. Mọi document đều bị đọc để trả về một cái, và query thứ hai còn phải sort trong bộ nhớ. Hai query đó sau một lần khởi động với auto-index-creation=true:
sku: plan EXPRESS_IXSCAN(sku) | nReturned 1 | totalKeysExamined 1 | totalDocsExamined 1 | executionTimeMillis best of 5: 0
category+price: plan FETCH <- IXSCAN(category_price) | nReturned 466 | totalKeysExamined 466 | totalDocsExamined 466 | executionTimeMillis best of 5: 0Load average 7,50. Một key và một document cho SKU (EXPRESS_IXSCAN là đường tắt của MongoDB 8 cho so sánh bằng trên unique index), và đúng 466 key khớp cho khoảng giá, đã theo thứ tự giá, nên stage SORT biến mất: trong một category, compound index được sắp theo field thứ hai của nó, price, đúng field mà query sort. Đo từ ứng dụng bằng MongoTemplate.find vào ProductListItem, 100 lời gọi sau 20 lời gọi khởi động:
| Query | Không index | Có index |
|---|---|---|
sku = GEN-123456 (1 dòng) | 56,33 ms mỗi query (load 6,50) | 0,62 ms (load 5,90) |
| tai nghe dưới 20, theo giá (466 dòng) | 69,78 ms (load 6,50) | 4,94 ms (load 5,90) |
4,94 ms còn lại phần lớn là đọc và map 466 document. explain là công cụ đầu tiên mỗi khi query chậm: COLLSCAN với totalDocsExamined lớn hơn nhiều so với nReturned là dấu hiệu của một index bị thiếu.
Một aggregation pipeline: điểm đánh giá trung bình theo category
Câu hỏi về nhiều document cùng lúc, như điểm đánh giá trung bình theo category, là việc của aggregation pipeline: một danh sách stage, mỗi stage biến đổi dòng document từ stage trước. Aggregation.newAggregation dựng pipeline từ các stage có type:
public List<CategoryRating> ratingsByCategory() {
Aggregation pipeline = Aggregation.newAggregation(
Aggregation.match(Criteria.where("stock").gt(0)),
Aggregation.unwind("reviews"),
Aggregation.group("category")
.avg("reviews.rating").as("averageRating")
.count().as("reviews"),
Aggregation.sort(Sort.by(Sort.Order.desc("averageRating"), Sort.Order.asc("_id"))),
Aggregation.project("averageRating", "reviews").and("category").previousOperation());
return mongo.aggregate(pipeline, Product.class, CategoryRating.class).getMappedResults();
}package com.example.demo.product;
public record CategoryRating(String category, double averageRating, int reviews) {
}MongoTemplate log pipeline mà nó gửi:
[
{ "$match": { "qty": { "$gt": 0 } } },
{ "$unwind": "$reviews" },
{ "$group": { "_id": "$category", "averageRating": { "$avg": "$reviews.rating" }, "reviews": { "$sum": 1 } } },
{ "$sort": { "averageRating": -1, "_id": 1 } },
{ "$project": { "averageRating": 1, "reviews": 1, "_id": 0, "category": "$_id" } }
]CategoryRating[category=monitor, averageRating=4.666666666666667, reviews=3]
CategoryRating[category=headphones, averageRating=4.333333333333333, reviews=6]
CategoryRating[category=keyboard, averageRating=4.333333333333333, reviews=6]Chạy pipeline bị cắt sau từng stage, gắn thêm một $count, cho thấy bao nhiêu document chảy giữa các stage:

$match đứng đầu để có thể dùng index và để ít dữ liệu chảy qua phần còn lại; màn hình LG hết hàng, cùng review 2 sao của nó, bị loại trước khi tính trung bình. $unwind biến mỗi product thành một document cho mỗi review, nên 7 product thành 15 document, và âm thầm bỏ những product không có review: tai nghe Sennheiser không bao giờ tới $group. {$unwind: {path: "$reviews", preserveNullAndEmptyArrays: true}} giữ lại product như vậy thành một document không có review; trong mongosh nó cho ra nhiều hơn $unwind thường đúng một document. $group tính trung bình ngay trong database, nên 15 review không bao giờ phải đi về ứng dụng. Tai nghe và bàn phím hoà nhau ở 26 / 6 = 4,33, nên pipeline sort thêm theo _id; không có tiêu chí phụ thì thứ tự của các giá trị bằng nhau không được đảm bảo.
Transaction của MongoDB cần replica set
Update trên một document là atomic mà không cần transaction. Đặt hàng thì chạm tới hai document trong hai collection: insert order, rồi trừ tồn kho, và nếu thiếu hàng thì order không được ở lại.
package com.example.demo.order;
import java.time.Instant;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.product.Product;
import com.mongodb.client.result.UpdateResult;
@Service
public class OrderService {
private final OrderRepository orders;
private final MongoTemplate mongo;
public OrderService(OrderRepository orders, MongoTemplate mongo) {
this.orders = orders;
this.mongo = mongo;
}
@Transactional
public Order place(String sku, int quantity) {
Order order = orders.insert(new Order(null, sku, quantity, Instant.now()));
UpdateResult stock = mongo.updateFirst(
Query.query(Criteria.where("sku").is(sku).and("stock").gte(quantity)),
new Update().inc("stock", -quantity),
Product.class);
if (stock.getModifiedCount() == 0) {
throw new InsufficientStockException(sku, quantity);
}
return order;
}
}Order là một record trong collection orders. Lệnh update có điều kiện chỉ trừ tồn kho khi còn đủ, chính là phép kiểm tra atomic của bài 7 dưới dạng MongoDB.
@Transactional không có MongoTransactionManager thì không làm gì cả
Thử nghiệm đặt một chiếc MN-27GP850, loại đang có tồn kho 0:
transaction managers: []
OrderService is a proxy: false
stock of MN-27GP850 before: 0
Inserting Document containing fields: [sku, quantity, placedAt, _class] in collection: orders
Calling update using query: { "sku" : "MN-27GP850", "qty" : { "$gte" : 1}} and update: { "$inc" : { "qty" : -1, "version" : 1}} in collection: products
com.example.demo.order.InsufficientStockException: Not enough stock of MN-27GP850 for 1
orders after the failed call: 1Boot 4.1.1 không tự cấu hình transaction manager cho MongoDB, và khi context không có bean TransactionManager nào, OrderService thậm chí không được proxy: annotation bị bỏ qua mà không có cảnh báo, và order cho món hàng không có thật vẫn nằm lại trong collection. Transaction manager chỉ là một bean:
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.data.mongodb.MongoTransactionManager;
@Configuration(proxyBeanMethods = false)
public class MongoConfig {
@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory databaseFactory) {
return new MongoTransactionManager(databaseFactory);
}
}Cùng lời gọi đó, với log DEBUG của transaction manager và command của driver (đã cắt các field của session):
transaction managers: [transactionManager]
OrderService is a proxy: true
Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
{"insert": "orders", "ordered": true, "$db": "catalog", "txnNumber": 1, "startTransaction": true, "autocommit": false, "documents": [{"_id": {"$oid": "6aace62721a2347324ca600d"}, "sku": "MN-27GP850", "quantity": 1, "placedAt": {"$date": "2026-09-18T07:20:07.48Z"}, "_class": "com.example.demo.order.Order"}]}
{"update": "products", "ordered": true, "$db": "catalog", "txnNumber": 1, "autocommit": false, "updates": [{"q": {"sku": "MN-27GP850", "qty": {"$gte": 1}}, "u": {"$inc": {"qty": -1, "version": 1}}}]}
Initiating transaction rollback
{"abortTransaction": 1, "$db": "admin", "txnNumber": 1, "autocommit": false}
com.example.demo.order.InsufficientStockException: Not enough stock of MN-27GP850 for 1
orders after the failed call: 0Lệnh insert mở transaction (startTransaction: true), cả hai lệnh ghi mang cùng txnNumber trên cùng session, và exception trở thành abortTransaction. Khi context chỉ có một TransactionManager, mọi @Transactional đều dùng nó; ứng dụng có thêm transaction manager của JPA hoặc JDBC thì có hai cái, và mỗi @Transactional phải chỉ rõ nó muốn cái nào.
Lỗi trên một server standalone
Một mongod standalone, tức docker run mongo:8 mặc định không có --replSet, không chạy được transaction. Cùng lời gọi đó trên một server như vậy:
org.springframework.data.mongodb.UncategorizedMongoDbException: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.
caused by com.mongodb.MongoClientException: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.Thông báo này gây hiểu lầm. Khi thêm retryWrites=false vào URI, exception đến tay code vẫn giống từng chữ, và chỉ command log của driver mới cho thấy câu trả lời thật của server cho lệnh insert:
com.mongodb.MongoCommandException: Command execution failed on MongoDB server with error 20 (IllegalOperation): 'Transaction numbers are only allowed on a replica set member or mongos' on server localhost:27112.Transaction dùng chung cơ chế transaction number với retryable write, và driver viết lại mã lỗi 20 của server thành lời khuyên về retryable write trong cả hai trường hợp. Cách sửa là replica set, một thành viên là đủ, chứ không phải connection string. Ngoài ra, transaction của MongoDB có những giới hạn mà transaction quan hệ không có: nó được thiết kế để ngắn (transactionLifetimeLimitSeconds, tuổi mà server sẽ abort một transaction, là 60 trên server này), và tốn kém hơn một lệnh ghi trên một document, nên câu hỏi thiết kế đến trước. Nếu order và phần trừ tồn kho có thể nằm trong một document, sẽ không cần transaction nào cả.
Bẫy của schema-less: một field bị đổi tên
Phiên bản trước của catalogue gọi phần mô tả là desc; phiên bản này đổi tên field thành description. MongoDB không có schema để migrate, nên việc deploy thành công, và các document do phiên bản trước ghi vẫn dùng desc. Hai trong số đó, được tái tạo bằng Document thô:
{"sku": "MS-MX3S", "name": "Logitech MX Master 3S", "desc": "Ergonomic wireless mouse with quiet clicks", "category": "mouse", ...}
{"sku": "SP-FLIP6", "name": "JBL Flip 6", "desc": "Portable wireless speaker, IP67", "category": "speaker", ...}Tìm "wireless" bỏ sót chúng, và nạp một cái lên thì thấy mô tả rỗng:
--- findByDescriptionContaining("wireless")
find using query: { "description" : { "$regularExpression" : { "pattern" : ".*wireless.*", "options" : ""}}} fields: Document{{}} sort: null for class: class com.example.demo.product.Product in collection: products
KB-K2-BRN
--- load a legacy product
SP-FLIP6 description=nullTệ hơn, entity không có chỗ cho một field nó không biết. Đổi giá của chiếc loa rồi lưu lại đã thay cả document, và phần mô tả cũ mất vĩnh viễn:
{"_id": {"$oid": "6aace69a0cef47a41e37c8a2"}, "sku": "SP-FLIP6", "name": "JBL Flip 6", "category": "speaker", "price": {"$numberDecimal": "119.00"}, "qty": 10, "attributes": {}, "reviews": [], "reviewCount": 0, "version": 1, "_class": "com.example.demo.product.Product"}Đổi tên trong một store schema-less là một đợt migrate dữ liệu mà bạn phải tự viết. Phiên bản nhỏ nhất an toàn để chạy ở mỗi lần khởi động là một $rename giới hạn trong những document còn field cũ:
@Component
@Order(0)
public class RenameDescToDescription implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(RenameDescToDescription.class);
private final MongoTemplate mongo;
public RenameDescToDescription(MongoTemplate mongo) {
this.mongo = mongo;
}
@Override
public void run(ApplicationArguments args) {
UpdateResult result = mongo.updateMulti(
Query.query(Criteria.where("desc").exists(true)),
new Update().rename("desc", "description"),
Product.class);
log.info("Renamed desc to description in {} product(s)", result.getModifiedCount());
}
}Calling update using query: { "desc" : { "$exists" : true}} and update: { "$rename" : { "desc" : "description"}, "$inc" : { "version" : 1}} in collection: products
Renamed desc to description in 1 product(s)
KB-K2-BRN
MS-MX3SCon chuột được tìm thấy trở lại; mô tả của chiếc loa, đã bị ghi đè trước khi migration chạy, thì không quay về nữa. Chạy lúc khởi động, trước khi các document cũ được insert, chính runner đó đã log Renamed desc to description in 0 product(s): khi không còn gì để đổi tên thì nó không làm gì, và đó là điều khiến nó an toàn ở mỗi lần khởi động. MongoTemplate tự thêm $inc trên version, nên một bản cũ đang nằm đâu đó vẫn không lưu được. Rút ra ba quy tắc. Chỉ đổi tên trong Java khi có @Field("desc") giữ tên đã lưu, hoặc đưa đợt migrate dữ liệu vào cùng bản release. Chạy migration trước khi code mới nhận traffic, đừng để làm dần. Và khi migration không còn là một update idempotent duy nhất, hãy chuyển sang một công cụ migration có version cho MongoDB, như Mongock, đóng vai trò mà Flyway đã đóng cho SQL ở Basics 31.
Redis làm nơi lưu dữ liệu: RedisTemplate và StringRedisTemplate
Redis giữ mọi thứ trong bộ nhớ, trả lời một command trong một phần nhỏ của mili giây (0,11 ms mỗi round trip trong lần đo pipelining bên dưới, tính cả mạng), và lưu value dưới dạng cấu trúc có type: string, hash, list, set, sorted set, stream. Spring Data Redis đến với nó qua một RedisConnectionFactory và hai template mà Boot cấu hình sẵn: redisTemplate, một RedisTemplate<Object, Object>, và stringRedisTemplate. Dùng Redis làm cache trước database, với @Cacheable, RedisCacheManager và serializer của nó, là bài 9; ở đây Redis giữ dữ liệu của riêng nó. Redis còn có pub/sub và stream, thuộc về phần messaging ở Chapter 5.
Serialization JDK mặc định nhìn từ redis-cli
Hai template khác nhau ở serializer, như lab đã in ra:
redisTemplate value serializer: JdkSerializationRedisSerializer, key serializer: JdkSerializationRedisSerializer, default: JdkSerializationRedisSerializer
stringRedisTemplate value serializer: StringRedisSerializerRedisTemplate<Object, Object> biến key và value thành byte của Java serialization. Lưu một product summary bằng nó:
package com.example.demo.product;
import java.io.Serializable;
import java.math.BigDecimal;
public record ProductSummary(String sku, String name, BigDecimal price) implements Serializable {
}redisTemplate.opsForValue().set("product:summary:" + summary.sku(), summary);
System.out.println("read back: " + redisTemplate.opsForValue().get("product:summary:" + summary.sku()));
stringRedisTemplate.opsForValue().set("greeting", "hello");
System.out.println("StringRedisTemplate reads it as: " + stringRedisTemplate.opsForValue().get("product:summary:" + summary.sku()));read back: ProductSummary[sku=KB-K2-BRN, name=Keychron K2 Wireless, price=89.00]
StringRedisTemplate reads it as: nullStringRedisTemplate không tìm thấy value dưới cùng key đó, và redis-cli cho thấy lý do:
docker exec sba-a11-redis redis-cli --no-raw KEYS '*'1) "product:summary:KB-K2-BRN"
2) "greeting"
3) "\xac\xed\x00\x05t\x00\x19product:summary:KB-K2-BRN"Key đầu tiên thuộc về template JSON ở phần tiếp theo, mà cùng lần chạy đã dùng sau đó. Key thứ ba là key này: bản thân key đã đi qua Java serialization, \xac\xed\x00\x05 là header của stream còn t\x00\x19 là một string 25 ký tự. Value, đọc bằng cách gõ key đã escape vào redis-cli, còn tệ hơn:
"\xac\xed\x00\x05sr\x00'com.example.demo.product.ProductSummary\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x03L\x00\x04namet\x00\x12Ljava/lang/String;L\x00\x05pricet\x00\x16Ljava/math/BigDecimal;…"455 byte, chỉ một JVM có com.example.demo.product.ProductSummary trên classpath ở phiên bản tương thích mới đọc được, và một record không có implements Serializable thất bại với SerializationException: Cannot serialize. Không gì khác, không redis-cli, không service Python, không lần refactor tiếp theo, dùng được dữ liệu đó.
Một serializer JSON cho value
Một template riêng, với key là string và value là JSON qua JacksonJsonRedisSerializer của Jackson 3 (Spring Data Redis 4.1.1 đánh dấu deprecated cho Jackson2JsonRedisSerializer của Jackson 2):
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import com.example.demo.product.ProductSummary;
@Configuration(proxyBeanMethods = false)
public class RedisConfig {
@Bean
RedisTemplate<String, ProductSummary> productSummaryTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, ProductSummary> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(RedisSerializer.string());
template.setValueSerializer(new JacksonJsonRedisSerializer<>(ProductSummary.class));
return template;
}
}@Component
public class ProductSummaryStore {
private final RedisTemplate<Object, Object> redis;
private final RedisTemplate<String, ProductSummary> redis;
public ProductSummaryStore(RedisTemplate<Object, Object> redis) {
public ProductSummaryStore(RedisTemplate<String, ProductSummary> redis) {
this.redis = redis;
}
public void put(ProductSummary summary) {
redis.opsForValue().set("product:summary:" + summary.sku(), summary);
}
public ProductSummary get(String sku) {
return redis.opsForValue().get("product:summary:" + sku);
}
}Sau store.put(summary), redis-cli --no-raw liệt kê key bằng SCAN và đọc nó bằng GET product:summary:KB-K2-BRN:
"product:summary:KB-K2-BRN"
"{\"sku\":\"KB-K2-BRN\",\"name\":\"Keychron K2 Wireless\",\"price\":89.00}"Một key đọc được, một value 63 byte mà ngôn ngữ nào cũng parse được, và store đọc lại đúng record đó. Chính các type parameter generic chọn bean: store yêu cầu RedisTemplate<String, ProductSummary> và nhận productSummaryTemplate. Với string và số thuần, phần lớn những gì sắp tới, StringRedisTemplate đã là lựa chọn đúng.
Cấu trúc dữ liệu Redis cho các trường hợp thực tế
Redis có chỗ đứng nhờ những gì mỗi cấu trúc làm được trong một command trên server: đếm, xếp hạng, cập nhật một field, hết hạn. Bốn component dưới đây đều dùng StringRedisTemplate; command dưới mỗi cái lấy từ MONITOR, lệnh in ra mọi command server nhận được. Hình dưới gom mọi key mà code Redis trong bài đã tạo, gồm cả key của repository @RedisHash ở phần sau đó nữa:

Bộ đếm lượt xem với INCR
@Component
public class ViewCounter {
private final StringRedisTemplate redis;
public ViewCounter(StringRedisTemplate redis) {
this.redis = redis;
}
public long increment(String sku) {
return redis.opsForValue().increment(key(sku));
}
public long count(String sku) {
String value = redis.opsForValue().get(key(sku));
return value == null ? 0 : Long.parseLong(value);
}
private static String key(String sku) {
return "views:product:" + sku;
}
}"INCR" "views:product:KB-K2-BRN"
"INCR" "views:product:KB-K2-BRN"INCR tạo key với giá trị 0 nếu chưa có, cộng một và trả về giá trị mới, tất cả bên trong Redis, nơi các command chạy lần lượt từng cái một. Cùng cuộc đua như với review, 20 thread mỗi thread tăng 500 lần, so sánh INCR với cách đọc rồi ghi bằng GET và SET:
GET + SET round 1: 10000 increments, counter = 907, 517 ms
GET + SET round 2: 10000 increments, counter = 908, 427 ms
GET + SET round 3: 10000 increments, counter = 897, 271 ms
INCR round 1: 10000 increments, counter = 10000, 138 ms
INCR round 2: 10000 increments, counter = 10000, 127 ms
INCR round 3: 10000 increments, counter = 10000, 114 msLoad average 5,31 đến 5,52. Đọc-sửa-ghi mất khoảng chín trên mười lần tăng, dù bản thân Redis là single-thread: khe hở nằm giữa GET và SET của ứng dụng, không nằm trong Redis. INCR đếm đủ 10.000, trong chưa tới một nửa thời gian vì chỉ một round trip thay vì hai. 20 thread dùng chung một connection: CLIENT LIST trong một lần chạy sau đó chỉ có một client Lettuce với tot-cmds=90015.
Bảng xếp hạng bán chạy với sorted set
Sorted set giữ các member theo thứ tự của score. Mỗi lần bán cộng số lượng vào score của product; top ba là một query theo khoảng:
@Component
public class BestsellerBoard {
private static final String KEY = "bestsellers";
private final StringRedisTemplate redis;
public BestsellerBoard(StringRedisTemplate redis) {
this.redis = redis;
}
public void recordSale(String sku, int quantity) {
redis.opsForZSet().incrementScore(KEY, sku, quantity);
}
public List<Bestseller> top(int n) {
return redis.opsForZSet().reverseRangeWithScores(KEY, 0, n - 1).stream()
.map(t -> new Bestseller(t.getValue(), t.getScore().longValue()))
.toList();
}
}Năm lần bán, rồi top ba:
"ZINCRBY" "bestsellers" "2.0" "KB-K2-BRN"
"ZINCRBY" "bestsellers" "1.0" "HP-WH1000XM5"
"ZINCRBY" "bestsellers" "1.0" "MN-U2723QE"
"ZINCRBY" "bestsellers" "3.0" "HP-WH1000XM5"
"ZINCRBY" "bestsellers" "1.0" "KB-K8-RED"
"ZREVRANGE" "bestsellers" "0" "2" "WITHSCORES"[Bestseller[sku=HP-WH1000XM5, sold=4], Bestseller[sku=KB-K2-BRN, sold=2], Bestseller[sku=MN-U2723QE, sold=1]]Score là số double, nên số lượng được gửi đi thành "2.0". Set luôn được giữ theo thứ tự khi cập nhật, nên đọc top ba không phải sort gì cả, trong khi một ORDER BY trên bảng bán hàng phải aggregate và sort ở mỗi request. Các member có score bằng nhau được sắp theo byte của member, nên MN-U2723QE và KB-K8-RED, cùng ở mức 1, được ZREVRANGE trả về theo thứ tự từ điển ngược.
Giỏ hàng dạng hash
Hash là một map nhỏ dưới một key, rất hợp với giỏ hàng: mỗi SKU một field, mỗi field một số lượng, mỗi field được đổi riêng:
@Component
public class CartStore {
private static final Duration IDLE_LIFETIME = Duration.ofDays(7);
private final StringRedisTemplate redis;
private final HashOperations<String, String, String> hashes;
public CartStore(StringRedisTemplate redis) {
this.redis = redis;
this.hashes = redis.opsForHash();
}
public void add(String customerId, String sku, int quantity) {
String key = key(customerId);
hashes.increment(key, sku, quantity);
redis.expire(key, IDLE_LIFETIME);
}
public void remove(String customerId, String sku) {
hashes.delete(key(customerId), sku);
}
public Map<String, Integer> items(String customerId) {
Map<String, Integer> items = new TreeMap<>();
hashes.entries(key(customerId)).forEach((sku, qty) -> items.put(sku, Integer.parseInt(qty)));
return items;
}
private static String key(String customerId) {
return "cart:" + customerId;
}
}"HINCRBY" "cart:c-1001" "KB-K2-BRN" "1"
"PEXPIRE" "cart:c-1001" "604800000"
"HINCRBY" "cart:c-1001" "HP-WH1000XM5" "1"
"PEXPIRE" "cart:c-1001" "604800000"
"HINCRBY" "cart:c-1001" "KB-K2-BRN" "2"
"PEXPIRE" "cart:c-1001" "604800000"
"HDEL" "cart:c-1001" "HP-WH1000XM5"
"HGETALL" "cart:c-1001"{KB-K2-BRN=3}HINCRBY đổi một field một cách atomic, giống $inc trong MongoDB, nên hai tab cùng thêm vào một giỏ không ghi đè nhau. expire(key, Duration) được gửi thành PEXPIRE theo mili giây, không phải EXPIRE, và mỗi thay đổi đẩy hạn chót lùi lại: giỏ hàng biến mất bảy ngày sau lần thay đổi cuối. redis-cli TTL cart:c-1001 trả về 604792 vài giây sau đó. Hạn này áp cho cả key. Redis 8.10 còn cho phép một field riêng lẻ trong hash hết hạn, khi một dòng trong giỏ cần hạn riêng: sau HEXPIRE probe:h 60 FIELDS 1 a, HTTL báo 60 giây cho field a và -1 (không hết hạn) cho field b.
Mã dùng một lần với TTL
Mã đăng nhập hoặc mã thanh toán phải hết hạn và chỉ dùng được một lần:
@Component
public class OneTimeCodes {
private static final Duration LIFETIME = Duration.ofMinutes(5);
private final StringRedisTemplate redis;
private final SecureRandom random = new SecureRandom();
public OneTimeCodes(StringRedisTemplate redis) {
this.redis = redis;
}
public String issue(String email) {
String code = "%06d".formatted(random.nextInt(1_000_000));
redis.opsForValue().set(key(email), code, LIFETIME);
return code;
}
public long secondsLeft(String email) {
return redis.getExpire(key(email));
}
public boolean verify(String email, String code) {
String stored = redis.opsForValue().getAndDelete(key(email));
return code.equals(stored);
}
private static String key(String email) {
return "otp:" + email;
}
}"SET" "otp:an@example.com" "641297" "EX" "300"
"TTL" "otp:an@example.com"
"GETDEL" "otp:an@example.com"
"GETDEL" "otp:an@example.com"issued 641297, seconds left 300
first verify: true
second verify: falseValue và thời gian sống của nó là một command, SET … EX 300, nên không có lúc nào mã tồn tại mà không có hạn, điều mà một SET rồi mới EXPIRE riêng sẽ cho phép nếu ứng dụng chết ở giữa. GETDEL đọc và xoá trong một bước: hai request cùng đua với một mã không thể cùng thành công, và lần verify thứ hai thất bại. Đoán sai cũng làm mất mã, một mặc định hợp lý cho sáu chữ số; một cài đặt thật sẽ thêm bộ đếm số lần thử theo email, một INCR nữa có kèm hạn.
Nối MongoDB và Redis vào REST API
Các controller kết hợp cả hai store. Đọc một product thì đếm một lượt xem trong Redis; một order được đặt trong transaction của MongoDB rồi mới ghi lên bảng xếp hạng:
@GetMapping("/{sku}")
public ProductResponse get(@PathVariable String sku) {
Product product = products.get(sku);
return ProductResponse.from(product, views.increment(sku));
}
@PostMapping("/{sku}/reviews")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void addReview(@PathVariable String sku, @Valid @RequestBody ReviewRequest request) {
products.addReview(sku, new Review(request.author(), request.rating(), request.comment(), Instant.now()));
}@PostMapping
public ResponseEntity<Order> place(@Valid @RequestBody OrderRequest request) {
Order order = orders.place(request.sku(), request.quantity());
bestsellers.recordSale(order.sku(), order.quantity());
return ResponseEntity.created(URI.create("/api/orders/" + order.id())).body(order);
}Redis không thuộc transaction của MongoDB. recordSale chạy sau khi place đã trả về, tức là sau commit: một order thất bại không bao giờ tới bảng xếp hạng, còn nếu Redis lỗi sau commit thì order vẫn tồn tại và bảng xếp hạng thiếu một lần bán, một đánh đổi chấp nhận được với số liệu thống kê và sai với tiền. Error handler theo advice ProblemDetail của series từ bài Basics 20, cùng body 422 cho lỗi validation, và thêm hai handler:
@ExceptionHandler(ProductNotFoundException.class)
ProblemDetail productNotFound(ProductNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(InsufficientStockException.class)
ProblemDetail insufficientStock(InsufficientStockException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}Sau khi seed lại, với ứng dụng khởi động bằng java -Xmx512m -jar build/libs/demo-0.0.1-SNAPSHOT.jar:
curl -s http://localhost:8211/api/products/KB-K8-RED{"sku":"KB-K8-RED","name":"Keychron K8 Pro","category":"keyboard","price":109.00,"stock":12,"brand":"Keychron","attributes":{"layout":"TKL","switchType":"red","wireless":true},"reviews":[{"author":"hoa","rating":4,"comment":"Quiet and smooth","createdAt":"2026-09-18T07:00:00Z"},{"author":"nam","rating":3,"comment":"Keycaps feel cheap","createdAt":"2026-09-18T07:00:00Z"}],"views":1}Lần gọi thứ hai trả về "views":2. Một SKU không tồn tại và một review không hợp lệ:
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"No product with SKU NOPE","instance":"/api/products/NOPE","status":404,"title":"Not Found"}
HTTP/1.1 422
Content-Type: application/problem+json
{"detail":"Request has 2 invalid value(s).","instance":"/api/products/KB-K8-RED/reviews","status":422,"title":"Unprocessable Content","errors":[{"field":"author","message":"must not be blank"},{"field":"rating","message":"must be less than or equal to 5"}]}Hai order, mỗi order hai chiếc HP-QC-ULTRA, khi tồn kho là ba:
curl -s -i -H "Content-Type: application/json" -d '{"sku":"HP-QC-ULTRA","quantity":2}' http://localhost:8211/api/ordersHTTP/1.1 201
Location: /api/orders/6aace8b1953cefdd3aaa828b
Content-Type: application/json
{"id":"6aace8b1953cefdd3aaa828b","sku":"HP-QC-ULTRA","quantity":2,"placedAt":"2026-09-18T07:30:57.759287Z"}
HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"Not enough stock of HP-QC-ULTRA for 2","instance":"/api/orders","status":409,"title":"Conflict"}Sau thêm một order cho một chiếc KB-K2-BRN, GET /api/bestsellers trả về [{"sku":"HP-QC-ULTRA","sold":2},{"sku":"KB-K2-BRN","sold":1}], và db.orders.countDocuments() là 2: order bị rollback không để lại gì, và HP-QC-ULTRA ở mức qty: 1.
Repository @RedisHash: các key chúng tạo và những gì TTL bỏ lại
Spring Data Redis cũng có repository: một entity có @RedisHash được lưu thành một hash của Redis và query qua CrudRepository. Một lần giữ hàng cho checkout, cần tự biến mất, là trường hợp phù hợp:
package com.example.demo.reservation;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
@RedisHash("reservations")
public record Reservation(@Id String id, @Indexed String sku, @Indexed String customerId, int quantity,
@TimeToLive long ttlSeconds) {
}package com.example.demo.reservation;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface ReservationRepository extends CrudRepository<Reservation, String> {
List<Reservation> findBySku(String sku);
List<Reservation> findByCustomerId(String customerId);
}Ba lần giữ hàng được lưu, hai lần cho KB-K2-BRN với TTL 15 giây và một lần cho HP-WH1000XM5 trong một giờ. MONITOR cho thấy một lần save gửi gì:
"DEL" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001"
"HMSET" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001" "_class" "com.example.demo.reservation.Reservation" "customerId" "c-1001" "id" "1a2b3c4d-5e6f-4000-8000-000000000001" "quantity" "1" "sku" "KB-K2-BRN" "ttlSeconds" "15"
"SADD" "reservations" "1a2b3c4d-5e6f-4000-8000-000000000001"
"EXPIRE" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001" "15"
"SADD" "reservations:sku:KB-K2-BRN" "1a2b3c4d-5e6f-4000-8000-000000000001"
"SADD" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:idx" "reservations:sku:KB-K2-BRN"
"SADD" "reservations:customerId:c-1001" "1a2b3c4d-5e6f-4000-8000-000000000001"
"SADD" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:idx" "reservations:customerId:c-1001"Tám command cho một entity, không cái nào nằm trong MULTI. Chúng tạo bốn loại key, tất cả đều thấy được bằng SCAN ngay sau khi lưu:
docker exec sba-a11-redis redis-cli --no-raw SCAN 0 MATCH 'reservations*' COUNT 1001) "0"
2) 1) "reservations:sku:HP-WH1000XM5"
2) "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:idx"
3) "reservations:1a2b3c4d-5e6f-4000-8000-000000000002"
4) "reservations:1a2b3c4d-5e6f-4000-8000-000000000003"
5) "reservations:1a2b3c4d-5e6f-4000-8000-000000000002:idx"
6) "reservations:sku:KB-K2-BRN"
7) "reservations"
8) "reservations:1a2b3c4d-5e6f-4000-8000-000000000003:idx"
9) "reservations:customerId:c-1002"
10) "reservations:1a2b3c4d-5e6f-4000-8000-000000000001"
11) "reservations:customerId:c-1001"| Key | Type | Chứa |
|---|---|---|
reservations:<id> | hash | các field của entity, cộng _class; key duy nhất có TTL |
reservations | set | mọi id, dùng cho count() và findAll() |
reservations:sku:KB-K2-BRN | set | các id có giá trị đó của một property @Indexed, mỗi giá trị một set |
reservations:<id>:idx | set | các index set mà entity này nằm trong, dùng để dọn chúng khi xoá |
Một derived query là một phép toán trên set rồi một HGETALL cho mỗi id: findBySku("KB-K2-BRN") gửi SINTER reservations:sku:KB-K2-BRN và hai HGETALL. Query được trả lời từ những set id này, nên chúng hoạt động theo so sánh bằng trên property @Indexed; một property không có @Indexed thì không có set nào để đọc. Mười sáu giây sau, hai lần giữ hàng ngắn đã hết hạn, và SCAN liệt kê chín key: hai hash đã biến mất, mọi thứ khác vẫn còn. Repository, khởi động mới hoàn toàn, sau đó trả lời:
count(): 3
findBySku(KB-K2-BRN): []
findAll(): [null, null, Reservation[id=1a2b3c4d-5e6f-4000-8000-000000000003, sku=HP-WH1000XM5, customerId=c-1001, quantity=1, ttlSeconds=3564]]count() là SCARD reservations và vẫn đếm các id đã hết hạn; findAll() trả về null cho từng cái; findBySku lọc bỏ các hash bị thiếu nhưng vẫn gửi một HGETALL cho mỗi cái. Redis cho hash hết hạn và không biết gì về các set đang trỏ tới nó. (ttlSeconds quay về là TTL còn lại, đọc bằng một command TTL.)
Keyspace event và phantom key
Cơ chế dọn dẹp có sẵn, nhưng mặc định bị tắt: auto-configuration của Boot đăng ký repository bằng một @EnableRedisRepositories trơn, với enableKeyspaceEvents là OFF. Tự khai báo annotation này sẽ thay thế phần đăng ký của Boot, vốn lùi lại ngay khi đã có một bean repository factory của Redis:
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
@Configuration(proxyBeanMethods = false)
@EnableRedisRepositories(basePackages = "com.example.demo.reservation", enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)
public class RedisRepositoryConfig {
}Lúc khởi động, ứng dụng đặt notify-keyspace-events (trước đó rỗng, sau đó là xE trong CONFIG GET) và subscribe bằng PSUBSCRIBE "__keyevent@*__:expired". Mỗi lần lưu giờ ghi thêm một key, một bản sao của hash sống lâu hơn bản gốc năm phút:
"DEL" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:phantom"
"HMSET" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:phantom" "_class" "com.example.demo.reservation.Reservation" "customerId" "c-1001" "id" "1a2b3c4d-5e6f-4000-8000-000000000001" "quantity" "1" "sku" "KB-K2-BRN" "ttlSeconds" "15"
"EXPIRE" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:phantom" "315"Khi bản gốc hết hạn, Redis publish event, ứng dụng đọc bản phantom để biết các giá trị index của entity, rồi gỡ nó khỏi mọi nơi:
"HGETALL" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:phantom"
"DEL" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:phantom"
"SREM" "reservations" "1a2b3c4d-5e6f-4000-8000-000000000001"
"SREM" "reservations:sku:KB-K2-BRN" "1a2b3c4d-5e6f-4000-8000-000000000001"
"SREM" "reservations:customerId:c-1001" "1a2b3c4d-5e6f-4000-8000-000000000001"
"DEL" "reservations:1a2b3c4d-5e6f-4000-8000-000000000001:idx"Sau cả hai lần hết hạn, chỉ còn sáu key của lần giữ hàng dài. Cơ chế dọn dẹp chỉ chạy trong một ứng dụng đang sống: khi đã bật event nhưng ứng dụng dừng trước lúc TTL hết, phần sót lại vẫn y như trước (count(): 3, hai null), và lần khởi động sau không sửa được, vì một event pub/sub chỉ đến được những client đang subscribe lúc đó và không được giữ lại cho ai khác. Với những entity mà thời gian sống là quan trọng, thiết kế chắc chắn hơn thường là các cấu trúc đơn giản ở phần trước, nơi một key với một TTL là toàn bộ entity, hoặc một job định kỳ gỡ các id treo.
Pipelining: 10.000 lệnh ghi trong một round trip
Mỗi command ở trên là một round trip qua mạng, và ứng dụng chờ từng reply trước khi gửi command tiếp theo. Pipelining gửi nhiều command mà không chờ, rồi đọc mọi reply ở cuối:
void oneByOne() {
for (int i = 0; i < N; i++) {
redis.opsForValue().set("stock:SKU-" + i, Integer.toString(i));
}
}
void pipelined() {
List<Object> replies = redis.executePipelined((RedisCallback<Object>) connection -> {
for (int i = 0; i < N; i++) {
connection.stringCommands().set(("stock:SKU-" + i).getBytes(UTF_8), Integer.toString(i).getBytes(UTF_8));
}
return null;
});
if (replies.size() != N) {
throw new IllegalStateException("expected " + N + " replies, got " + replies.size());
}
}Với N = 10.000, sau một vòng khởi động:
round 1: 10000 SETs one by one 1209 ms, pipelined 55 ms
round 2: 10000 SETs one by one 1104 ms, pipelined 47 ms
round 3: 10000 SETs one by one 1153 ms, pipelined 46 ms
round 4: 10000 SETs one by one 1130 ms, pipelined 44 ms
round 5: 10000 SETs one by one 1118 ms, pipelined 38 msLoad average 5,52 trước, 4,98 sau. Khoảng 0,11 ms mỗi command khi gửi từng cái, gần như toàn bộ là round trip qua mạng của Docker, so với 38 đến 55 ms cho cả lô: nhanh hơn 20 đến 30 lần. Callback trả về null: các reply chỉ tồn tại sau khi pipeline được flush, và executePipelined trả chúng về thành một list, mỗi command một phần tử, điều mà phép kiểm tra replies.size() dựa vào. Pipeline không phải transaction: command của client khác có thể chạy xen giữa các command của bạn, và một command lỗi không chặn các command khác. Khi một nhóm command phải chạy mà không có gì xen vào, Redis có MULTI/EXEC (SessionCallback trong Spring Data Redis) và Lua script.
Lần chạy đầu của benchmark này, khi các client MONITOR từ những lần ghi log trước vẫn còn kết nối, đo được 1.626 đến 2.452 ms cho cách gửi từng cái: MONITOR chép mọi command cho từng client đang theo dõi, nên hãy ngắt nó trước khi đo bất cứ thứ gì.
Lettuce, client Redis mặc định
Starter mang theo Lettuce, và Boot dựng connection factory từ nó:
RedisConnectionFactory: org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
RedisTemplate bean productSummaryTemplate: RedisTemplate
RedisTemplate bean redisTemplate: RedisTemplate
RedisTemplate bean stringRedisTemplate: StringRedisTemplateLúc kết nối, Lettuce gửi HELLO 3 (giao thức RESP3) và tự giới thiệu, nhờ vậy CLIENT LIST gọi được tên nó:
"HELLO" "3"
"CLIENT" "SETINFO" "lib-name" "Lettuce(spring-data-redis_v4.1.1)"
"CLIENT" "SETINFO" "lib-ver" "7.5.2.RELEASE/5728917"id=75 addr=192.168.65.1:40321 … cmd=exists user=default redir=-1 resp=3 lib-name=Lettuce(spring-data-redis_v4.1.1) lib-ver=7.5.2.RELEASE/5728917 …Lettuce được xây trên Netty và connection của nó thread-safe, nên mặc định cả ứng dụng dùng chung một connection, như CLIENT LIST trong cuộc đua bộ đếm 20 thread đã cho thấy. Jedis, được BOM của Boot 4.1.1 quản lý ở bản 7.4.1, là client còn lại được hỗ trợ, và spring.data.redis.client-type chọn giữa hai cái; mô tả của nó trong metadata của Boot ghi "By default, auto-detected according to the classpath".
PostgreSQL jsonb, MongoDB hay Redis: chọn cái nào?
Trước khi thêm MongoDB chỉ vì attribute linh hoạt, phương án quan hệ đáng được chạy thử một lần. PostgreSQL 18.6, một cột jsonb cho attribute, 300.005 dòng và một GIN index:
create table products (
id bigint generated always as identity primary key,
sku varchar(40) not null unique,
name varchar(120) not null,
category varchar(40) not null,
price numeric(10, 2) not null,
attributes jsonb not null default '{}'
);
insert into products (sku, name, category, price, attributes) values
('KB-K2-BRN', 'Keychron K2 Wireless', 'keyboard', 89.00, '{"layout": "75%", "switchType": "brown", "wireless": true}'),
('KB-K8-RED', 'Keychron K8 Pro', 'keyboard', 109.00, '{"layout": "TKL", "switchType": "red", "wireless": true}'),
('KB-G915-TKL', 'Logitech G915 TKL', 'keyboard', 199.99, '{"layout": "TKL", "switchType": "brown", "wireless": true, "lowProfile": true}'),
('MN-U2723QE', 'Dell UltraSharp U2723QE', 'monitor', 579.00, '{"sizeInches": 27, "resolution": "3840x2160", "panel": "IPS Black", "usbC": true}'),
('HP-WH1000XM5', 'Sony WH-1000XM5', 'headphones', 329.00, '{"type": "over-ear", "anc": true, "batteryHours": 30}');
insert into products (sku, name, category, price, attributes)
select 'GEN-' || lpad(i::text, 6, '0'), 'Generated ' || i, 'keyboard', 50 + (i % 100),
jsonb_build_object('layout', 'TKL', 'switchType', (array['red', 'blue', 'black', 'silver'])[1 + i % 4], 'wireless', i % 2 = 0)
from generate_series(1, 300000) as i;
create index products_attributes_gin on products using gin (attributes jsonb_path_ops);
analyze products;
select sku, name, attributes ->> 'layout' as layout
from products
where attributes @> '{"switchType": "brown"}';
explain (analyze, costs off, timing off, summary off)
select sku from products where attributes @> '{"switchType": "brown"}';
select sku, (attributes ->> 'sizeInches')::int as size
from products
where category = 'monitor' and (attributes ->> 'sizeInches')::int >= 27;docker run -d --name sba-a11-pg --memory 256m -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=catalog postgres:18docker cp jsonb.sql sba-a11-pg:/tmp/jsonb.sqldocker exec sba-a11-pg psql -U demo -d catalog -f /tmp/jsonb.sqlSau các dòng xác nhận CREATE TABLE, INSERT và CREATE INDEX:
sku | name | layout
-------------+----------------------+--------
KB-K2-BRN | Keychron K2 Wireless | 75%
KB-G915-TKL | Logitech G915 TKL | TKL
(2 rows)
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on products (actual rows=2.00 loops=1)
Recheck Cond: (attributes @> '{"switchType": "brown"}'::jsonb)
Heap Blocks: exact=1
Buffers: shared hit=3
-> Bitmap Index Scan on products_attributes_gin (actual rows=2.00 loops=1)
Index Cond: (attributes @> '{"switchType": "brown"}'::jsonb)
Index Searches: 1
Buffers: shared hit=2
Planning:
Buffers: shared hit=1
(10 rows)
sku | size
------------+------
MN-U2723QE | 27
(1 row)Cùng query attribute như findKeyboardsBySwitch, được trả lời qua GIN index với ba shared buffer bị chạm tới trong số 300.005 dòng, bên cạnh foreign key, join và transaction mà phần còn lại của catalogue đã dùng. So sánh có type trên sizeInches cần một phép cast, cái giá của những attribute mà schema không biết tới. Với một ứng dụng quan hệ mà nhu cầu phi quan hệ duy nhất là một túi attribute, đó thường là câu trả lời.
PostgreSQL (với jsonb) | MongoDB | Redis | |
|---|---|---|---|
| Hình dạng dữ liệu | dòng với schema cố định; jsonb cho phần thay đổi | document: object và mảng lồng nhau, khác nhau giữa các document | key chứa string, hash, list, set, sorted set, stream |
| Query | SQL: join, aggregate, window function; GIN index trên jsonb | filter trên mọi đường dẫn, secondary index, aggregation pipeline, $lookup | chỉ theo key; @RedisHash thêm tra cứu bằng nhau qua set |
| Nhất quán và transaction | ACID trên bao nhiêu dòng và bảng cũng được | ghi một document là atomic; transaction nhiều document trên replica set, thời gian ngắn | mỗi command là atomic; MULTI/EXEC và Lua script, không có rollback |
| Độ bền | write-ahead log, xuống đĩa khi commit | journal, trên đĩa; replica set để dự phòng | trong bộ nhớ; snapshot RDB và AOF log tuỳ chọn |
| Dùng điển hình | nguồn dữ liệu gốc: order, tồn kho, tiền | aggregate đọc như một khối: catalogue, hồ sơ, nội dung, event | bộ đếm, xếp hạng, session, rate limit, mã ngắn hạn, cache |
Cách kết hợp trong bài này khá phổ biến: catalogue trong MongoDB hoặc PostgreSQL, những con số thay đổi nhanh và trạng thái ngắn hạn trong Redis, và mỗi mẩu dữ liệu nằm ở đúng một nơi.
FAQ
Spring Boot có tự tạo index MongoDB từ @Indexed không?
Không, trong Boot 4.1.1. spring.data.mongodb.auto-index-creation đang tắt, và sau một lần khởi động có @Indexed(unique = true) trên sku, collection chỉ có _id_. Khi đặt property thành true, Spring Data tạo sku (unique) và @CompoundIndex lúc khởi động, thứ mà trên 300.000 document cộng thêm khoảng 0,8 s vào thời gian khởi động. Hãy bật nó cho môi trường dev, và tạo index production từ một bước migration.
Tại sao @Transactional không rollback các lệnh ghi MongoDB?
Vì Boot không tạo MongoTransactionManager. Không có bean transaction manager nào thì service không được proxy và @Transactional bị bỏ qua âm thầm: order được insert trước lần trừ tồn kho thất bại vẫn nằm lại. Hãy khai báo MongoTransactionManager làm bean, và chạy MongoDB dạng replica set; server standalone từ chối transaction với thông báo gợi ý sai về retryWrites=false.
Nên dùng String hay ObjectId cho id của document Spring Data MongoDB?
Id String thường tiện nhất: Spring Data lưu nó thành ObjectId mỗi khi giá trị là 24 ký tự hex hoặc được tự sinh, còn code Java và JSON chỉ thấy một chuỗi bình thường. Giá trị không phải hex hợp lệ, như một SKU, được lưu thành string. Khi chương trình khác cũng ghi vào cùng collection, hãy cố định type được lưu bằng @MongoId(FieldType.OBJECT_ID) hoặc @MongoId(FieldType.STRING) để findById tìm đúng type mà chúng đã ghi.
Field _class trong Spring Data MongoDB là gì?
Nó lưu type Java đã ghi document, để property đa hình đọc lại được: một List<Discount> có phần tử bị mất _class thất bại với MappingInstantiationException. Với property có runtime type đúng bằng type khai báo, như review embedded, Spring Data không ghi _class. Dùng @TypeAlias để lưu một tên ngắn, ổn định thay cho tên class đầy đủ.
Tại sao key Redis không đọc được trong redis-cli?
Vì redisTemplate của Boot là một RedisTemplate<Object, Object> serialize key và value bằng Java serialization, nên product:summary:KB-K2-BRN được lưu thành \xac\xed\x00\x05t\x00\x19product:summary:KB-K2-BRN và StringRedisTemplate thậm chí không thấy được nó. Hãy dùng StringRedisTemplate cho string và số, và một RedisTemplate<String, T> với key dùng RedisSerializer.string() và value dùng JacksonJsonRedisSerializer cho object.
Entity @RedisHash có @TimeToLive có tự dọn các key index không?
Mặc định là không. Hash hết hạn, nhưng id vẫn nằm trong set của entity và trong mọi set @Indexed, nên count() vẫn đếm các entity đã hết hạn và findAll() trả về các null. @EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP) thêm phantom key và dọn dẹp khi có event hết hạn, nhưng chỉ khi có một instance ứng dụng đang chạy vào lúc key hết hạn.
Redis có đủ bền để làm nơi lưu duy nhất cho dữ liệu không?
Còn tuỳ vào cấu hình persistence trên server và mức mất mát chấp nhận được: Redis phục vụ mọi thứ từ bộ nhớ và ghi xuống đĩa qua snapshot RDB và file append-only tuỳ chọn. Bộ đếm, bảng xếp hạng, giỏ hàng và mã dùng một lần chịu được việc mất vài giây cuối; order và thanh toán thì không, nên trong bài này chúng ở lại MongoDB, còn Redis chỉ giữ những gì dựng lại được hoặc mất được.
Kết luận
Interface repository là phần quen thuộc của Spring Data MongoDB và Spring Data Redis; store phía sau quyết định mọi thứ còn lại. Trong MongoDB, document là đơn vị của tính atomic, nên một operator như $push hay $inc trên một document là cách ghi an toàn, còn load-modify-save làm mất tới 49 trên 50 review mà không có lỗi nào cho đến khi @Version biến chỗ mất đó thành exception. Index không được tạo từ @Indexed trừ khi bạn yêu cầu, @Transactional không làm gì cho đến khi có bean MongoTransactionManager và một replica set đang chạy, và một field bị đổi tên là một đợt migrate dữ liệu không ai viết hộ bạn. Trong Redis, cấu trúc dữ liệu chính là tính năng: INCR, ZINCRBY, HINCRBY, SET … EX và GETDEL mỗi cái làm trong một command atomic điều mà nếu không sẽ là một race condition, RedisTemplate mặc định lưu byte chỉ Java đọc được, @RedisHash bỏ lại key index khi entry hết hạn, và pipelining biến 1,1 s round trip thành khoảng 45 ms.
Bài tiếp theo vẫn ở Chapter 2 và quay lại phía quan hệ, với hai pattern làm thay đổi những gì mọi query phải kèm theo: multi-tenancy, nơi dữ liệu của mỗi khách hàng được tách bằng tenant id, schema hoặc database, và soft delete, nơi một dòng bị xoá chỉ được đánh dấu là đã xoá.