what happens when you call a rest api update?
Tung Bui Thanh (TungDaDev)27 mins read

Bạn click "Save" trên UI. Một PUT request bay đi. 200ms sau, data đã update. Nhưng trong 200ms đó, hàng chục components tham gia xử lý: từ TCP handshake, reverse proxy, security filters, serialization, transaction management, connection pooling, cho tới physical disk write trên database server.
Bài này trace toàn bộ journey của 1 UPDATE request từ client đến disk, qua từng layer. Với mỗi layer, mình sẽ nói nó làm gì, tại sao cần nó, và chuyện gì có thể sai.
Scenario: PUT /api/v1/products/550e8400-e29b-41d4-a716-446655440000 update tên và giá sản phẩm, backend Spring Boot, database PostgreSQL (relational) và MongoDB (document).
# phase 1: client → network → server (0-50ms)
# client tạo http request
Browser/mobile app serialize data thành JSON, build HTTP request:
PUT /api/v1/products/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
X-Request-Id: req-7f3a2b1c-4d5e-6f7a
Content-Length: 89
{"name": "Laptop Pro 2024", "price": 25990000}
Tại điểm này, chuyện gì đang xảy ra ở tầng network:
- DNS resolution:
api.example.com→ IP address (cached hoặc query DNS server) - TCP 3-way handshake: SYN → SYN-ACK → ACK (1 round-trip)
- TLS handshake: ClientHello → ServerHello → Certificate → Key Exchange (1-2 round-trips)
- HTTP request gửi qua encrypted TLS tunnel
Nếu connection đã mở sẵn (keep-alive của HTTP/1.1, hoặc HTTP/2): TCP + TLS handshake đã xong từ request trước nên request này đi luôn trên connection cũ, không tốn thêm round-trip nào. Riêng HTTP/2 còn cho chạy nhiều request song song trên cùng một connection (multiplexing), không phải xếp hàng chờ request trước trả về như HTTP/1.1. Đó mới là chỗ HTTP/2 hơn hẳn với các request tiếp theo.
# load balancer / reverse proxy (nginx/haproxy)
Request đến IP của load balancer trước, không phải trực tiếp đến app server.
Client → [Load Balancer :443] → [App Server :8080]
Load balancer làm gì:
- TLS Termination: Decrypt HTTPS → forward HTTP nội bộ (hoặc re-encrypt)
- Health check: Chỉ route đến healthy instances
- Load balancing: Round-robin, least-connections, hoặc weighted
- Rate limiting: Reject nếu client exceed quota (429 Too Many Requests)
- Request logging: Access log cho monitoring
Sau TLS termination, load balancer forward request (plain HTTP) đến 1 instance của Spring Boot app. Header X-Forwarded-For chứa client IP gốc, X-Forwarded-Proto = "https". Lưu ý Spring Boot không tự tin mấy header này: phải bật server.forward-headers-strategy=native (hoặc framework) thì request.getScheme(), getRemoteAddr() và các link redirect mới đúng là https và IP thật của client.
# api gateway (optional: kong, spring cloud gateway)
Nếu có API Gateway layer (microservices architecture):
Client → LB → [API Gateway] → [Product Service]
Gateway thêm:
- Authentication verification: Validate JWT token (check signature, expiry, issuer)
- Route resolution:
/api/v1/products/**→ product-service instance - Request transformation: Add headers, strip prefixes
- Circuit breaker: Reject nếu downstream service unhealthy
- Distributed tracing: Inject trace ID header (OpenTelemetry)
# phase 2: spring boot: servlet container (tomcat)
# tomcat nhận request
Spring Boot embed Tomcat server (default port 8080). Khi request đến:
- Acceptor thread nhận TCP connection từ NIO connector
- Poller thread detect data available trên connection
- Worker thread (từ thread pool, default max 200) assigned để xử lý request
- Tomcat parse HTTP: method, URI, headers, body stream
Tomcat Thread Pool (default):
├── max-threads: 200 (worker threads)
├── min-spare-threads: 10 (idle threads ready)
├── max-connections: 8192 (số connection Tomcat giữ cùng lúc)
├── accept-count: 100 (backlog của OS khi đã đủ max-connections)
└── connection-timeout: 60000ms (Tomcat standalone đặt 20000 trong server.xml)
200 threads bận → request mới nằm chờ trong Tomcat (tới 8192 connection)
Đủ 8192 connection + backlog đầy 100 → OS từ chối/bỏ qua kết nối mới,
client thấy connection refused hoặc timeout (không phải HTTP 503)
Điều quan trọng: Từ đây, toàn bộ request processing xảy ra trên 1 worker thread duy nhất (blocking model). Thread này bị chiếm cho đến khi response gửi xong, nên kích thước thread pool ảnh hưởng trực tiếp đến throughput. Từ Spring Boot 3.2 chạy trên Java 21, bật spring.threads.virtual.enabled=true thì mỗi request chạy trên một virtual thread: lúc chờ I/O (DB, HTTP call) nó nhả carrier thread ra, nên giới hạn 200 thread không còn là nút thắt. Khi đó nút thắt dời xuống connection pool của database.
# servlet filter chain
Trước khi đến Controller, request đi qua chuỗi Servlet Filters. Mỗi filter có thể: modify request, reject request, hoặc pass forward.
Request → [Filter 1] → [Filter 2] → [Filter 3] → ... → [DispatcherServlet]
↓
Response ← [Filter 3] ← [Filter 2] ← [Filter 1] ← ... ← [Controller]
Filters thường gặp (theo thứ tự):
// 1. CORS Filter — check Origin header, add CORS response headers
// Nếu Origin không allowed → 403 ngay, không đến Controller
// 2. Security Filter Chain (Spring Security)
// a. SecurityContextHolderFilter — load/create SecurityContext
// (Spring Security 6 thay cho SecurityContextPersistenceFilter đã deprecated)
// b. BearerTokenAuthenticationFilter (OAuth2 Resource Server)
// hoặc JwtAuthenticationFilter tự viết — extract + validate JWT token
// c. AuthorizationFilter — check URL-level permissions
// Nếu token invalid/expired → 401 ngay
// 3. Request Logging Filter — log request in (method, URI, headers)
// 4. ContentCachingRequestWrapper — buffer body cho re-read (logging/audit)# spring security: jwt validation chi tiết:
// Bên trong JwtAuthenticationFilter, đây là những gì xảy ra:
// 1. Extract token từ "Authorization: Bearer xxx" header
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response); // không có token → để filter sau quyết định (thường là 401)
return;
}
String token = header.substring(7);
// 2. Decode JWT (3 parts: header.payload.signature, base64url encoded)
// Header: {"alg":"RS256","kid":"key-id-123"}
// Payload: {"sub":"user-001","roles":["EDITOR"],"exp":1719849600,"iss":"auth.example.com"}
// 3. Verify signature (RSA/ECDSA)
// - Fetch public key từ JWKS endpoint (cached): auth.example.com/.well-known/jwks.json
// - Verify: signature matches header+payload using public key
// - Nếu sai → 401 Unauthorized
// 4. Check claims:
// - exp > now? (token chưa hết hạn?)
// - iss == expected issuer?
// - aud contains expected audience?
// 5. Extract authorities từ token (roles/permissions)
// → Tạo Authentication object → set vào SecurityContext
// → SecurityContext attached vào current thread (ThreadLocal)
// 6. Filter chain continues...Chuyện gì có thể sai ở đây:
- Token expired → 401 (client cần refresh token)
- Token signature invalid → 401 (token bị tamper hoặc key rotation)
- JWKS endpoint down → 401 hoặc 500 (tùy caching strategy)
- Rate limit exceeded → 429
# phase 3: spring mvc: request handling
# dispatcherservlet: front controller
DispatcherServlet là trái tim của Spring MVC. Nó nhận mọi request và dispatch đến đúng handler.
DispatcherServlet workflow:
1. HandlerMapping: "PUT /api/v1/products/{id}" → ProductController.update()
2. HandlerAdapter: setup method arguments (path variables, request body, headers)
3. Execute handler method
4. HandlerMethodReturnValueHandler: serialize return value → HTTP response
# argument resolution: parse request thành java objects
Trước khi gọi controller method, Spring resolve mỗi parameter:
@PutMapping("/{id}")
public ResponseEntity<APIResponse<ProductDTO>> update(
@PathVariable UUID id, // ← PathVariableMethodArgumentResolver
@Valid @RequestBody UpdateProductRequest request, // ← RequestBodyMethodArgumentResolver
@RequestHeader("X-Request-Id") String requestId // ← RequestHeaderMethodArgumentResolver
) { ... }@RequestBody resolution chi tiết:
- Đọc request body bytes từ InputStream
- Check
Content-Type: application/json→ chọn JacksonMappingJackson2HttpMessageConverter(Spring Framework 7 / Spring Boot 4 chuyển sang Jackson 3 vớiJacksonJsonHttpMessageConverter, class cũ bị deprecated) - Jackson
ObjectMapper.readValue(bytes, UpdateProductRequest.class)
- Đọc JSON theo từng token (streaming
JsonParser), không dựng câyJsonNodetrước rồi mới map - Map fields theo tên (hoặc @JsonProperty)
- Call constructor/setters để tạo Java object
- Nếu JSON malformed →
HttpMessageNotReadableException→ 400
@Valid, Bean Validation trigger:
- Hibernate Validator scan annotations trên
UpdateProductRequest - Validate từng field:
@NotBlank,@Positive,@Size... - Nếu violations →
MethodArgumentNotValidException→ 400 (Spring mặc định đã trả 400; muốn body lỗi theo format riêng thì bắt exception này trong@RestControllerAdvice) - Nếu pass → object sẵn sàng dùng
@Data
public class UpdateProductRequest {
@NotBlank(message = "Name is required")
@Size(max = 255)
private String name;
@NotNull @Positive(message = "Price must be positive")
private BigDecimal price;
}# controller method execution
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PutMapping("/{id}")
public ResponseEntity<APIResponse<ProductDTO>> update(
@PathVariable UUID id,
@Valid @RequestBody UpdateProductRequest request,
@RequestHeader("X-Request-Id") String requestId) {
// Tại đây: chỉ delegate — không có business logic trong controller
ProductDTO updated = productService.update(id, request);
return ResponseEntity.ok(APIResponse.success(updated));
}
}Controller chỉ là "adapter": chuyển thế giới HTTP (request/response) sang thế giới ứng dụng (gọi service). Không có tí business logic nào ở đây.
# phase 4: service layer: business logic & transaction
# @Transactional: transaction bắt đầu
Khi thread enter method đánh @Transactional, Spring AOP proxy intercept:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository; // JPA
private final ProductDocumentRepository documentRepository; // MongoDB
@Transactional // readOnly = false → write transaction
public ProductDTO update(UUID id, UpdateProductRequest request) {
// ← TẠI ĐÂY: Spring TransactionInterceptor bắt đầu transaction
// 1. TransactionManager.getTransaction() called
// 2. DataSource.getConnection() → lấy connection từ HikariCP pool
// 3. connection.setAutoCommit(false) → bắt đầu DB transaction
// 4. Connection bind vào ThreadLocal (TransactionSynchronizationManager)
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// Business logic
product.setName(request.getName());
product.setPrice(request.getPrice());
product.setUpdatedAt(LocalDateTime.now());
// Save to PostgreSQL
Product saved = productRepository.save(product);
// Save metadata to MongoDB
documentRepository.updateMetadata(id, Map.of(
"lastModified", LocalDateTime.now(),
"modifiedBy", SecurityUtils.getCurrentUserId()
));
return toDTO(saved);
// ← KHI METHOD RETURN THÀNH CÔNG:
// 1. TransactionInterceptor calls commit()
// 2. connection.commit() → PostgreSQL commit
// 3. Connection return về HikariCP pool
// 4. ThreadLocal cleanup
// ← NẾU RuntimeException/Error THROW (checked exception mặc định vẫn COMMIT,
// muốn rollback phải khai báo rollbackFor):
// 1. TransactionInterceptor calls rollback()
// 2. connection.rollback() → PostgreSQL rollback
// 3. Connection return pool
// 4. Exception propagate lên controller → error response
}
}Có một cái bẫy nằm ngay trong method này: @Transactional ở đây dùng transaction manager của JPA, nó chỉ bọc PostgreSQL. Lệnh ghi MongoDB chạy ngay lập tức, không nằm trong transaction đó. Nếu MongoDB ghi xong mà sau đó PostgreSQL commit lỗi (vd optimistic lock, mất connection) thì PostgreSQL rollback còn MongoDB vẫn giữ metadata mới, hai bên lệch nhau. Đây là bài toán dual write. Cách xử lý hay gặp là chỉ ghi PostgreSQL trong transaction, rồi đồng bộ sang MongoDB sau commit qua event AFTER_COMMIT hoặc outbox pattern (ghi event vào bảng outbox cùng transaction, một worker đọc ra và đẩy sang MongoDB).
# connection pool (hikaricp): behind the scenes
Khi DataSource.getConnection() được gọi, HikariCP không tạo connection mới mà lấy từ pool:
HikariCP Pool State:
├── Total connections: 20 (maximum-pool-size, mặc định là 10)
├── Active (đang dùng): 8
├── Idle (sẵn sàng): 12
└── Waiting threads: 0
Khi getConnection():
1. Kiểm pool có idle connection không?
├── CÓ → return ngay (< 1ms)
└── KHÔNG + pool chưa full → pool tạo thêm connection ở thread nền,
│ thread hiện tại chờ (~vài chục ms cho PostgreSQL)
└── KHÔNG + pool đã full → thread WAIT (block) cho đến connectionTimeout (30s default)
└── Timeout → SQLTransientConnectionException
(Spring bọc thành CannotCreateTransactionException) → 500
Connection lifecycle:
- Được tạo bởi pool (TCP connect to PostgreSQL: 3-way handshake + auth)
- Reused across transactions (dozens/hundreds of times)
- Health-checked bởi pool (JDBC isValid() mặc định, hoặc connectionTestQuery nếu cấu hình)
- Evicted khi idle quá lâu (idleTimeout) hoặc sống quá lâu (maxLifetime)
Performance insight: Mỗi connection = 1 TCP socket + 1 PostgreSQL backend process (vài MB tới vài chục MB RAM trên DB server, tuỳ work_mem và lượng metadata nó cache). Pool 20 connections = 20 backend processes, và nhân lên theo số instance của app. Vì vậy maximum-pool-size không nên quá lớn: tốn RAM DB server và thêm context-switch overhead.
# aop proxying: cách @Transactional thực sự hoạt động
Spring không sửa bytecode class của bạn (trừ khi dùng AspectJ weaving). Lúc khởi động, nó sinh ra một proxy object bọc ngoài service thật (Spring Boot mặc định dùng CGLIB, tức là một subclass được sinh lúc runtime):
Khi inject ProductService:
Controller nhận → CGLIB Proxy (subclass of ProductService)
KHÔNG phải real ProductService instance
Call flow:
controller.update(id, request)
→ proxy.update(id, request) // Proxy intercept
→ TransactionInterceptor.invoke() // Begin TX
→ realService.update(id, request) // Actual business logic
→ TransactionInterceptor.commit() // Commit TX (nếu success)
← return result to controller
Đây là lý do self-invocation bypass @Transactional: khi method A gọi this.methodB() trong cùng class, nó gọi trực tiếp (không qua proxy) → không có transaction cho methodB.
# phase 5: jpa/hibernate: object-relational mapping
# findbyid: select query
Product product = productRepository.findById(id).orElseThrow();Bên dưới, Hibernate:
- Check 1st Level Cache (Persistence Context / EntityManager): entity với id này đã load trong session chưa?
- CÓ → return cached entity (0 SQL queries!)
- KHÔNG → tiếp bước 2
- Check 2nd Level Cache (nếu enabled): entity có trong shared cache không?
- CÓ → hydrate entity từ cache
- KHÔNG → tiếp bước 3
- Generate SQL:
SELECT * FROM products WHERE id = $1 - Get PreparedStatement từ connection
- Set parameter:
ps.setObject(1, uuid) - Execute query → ResultSet
- Hydration: Map ResultSet columns → Java object fields
- Đặt entity vào Persistence Context (1st level cache)
- Set entity state =
MANAGED(Hibernate track changes từ đây)
-- SQL thực tế được generate (với format_sql=true):
SELECT
p.id, p.name, p.price, p.code, p.status,
p.category_id, p.created_at, p.updated_at, p.version
FROM products p
WHERE p.id = '550e8400-e29b-41d4-a716-446655440000'# dirty checking: detect changes
Sau khi product.setName("Laptop Pro 2024") và product.setPrice(25990000):
Hibernate KHÔNG gửi UPDATE ngay. Nó chỉ modify Java object trong memory. Entity vẫn MANAGED trong Persistence Context.
Khi nào UPDATE thực sự xảy ra?
- Khi
flush()được gọi (explicit hoặc auto) - Auto-flush triggers:
- Trước khi execute query (FlushMode AUTO, để query thấy data mới nhất)
- Trước khi transaction commit
- Khi gọi
repository.saveAndFlush()hoặcflush(). Cònsave()thường thì không flush.
# repository.save(): persist changes
Product saved = productRepository.save(product);Spring Data JPA's save() implementation:
// SimpleJpaRepository.save() — source code simplified
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity); // INSERT lúc flush (entity mới: id hoặc @Version còn null)
return entity;
} else {
return em.merge(entity); // entity đã có: copy state, UPDATE (nếu có) lúc flush
}
}Vì entity đã có ID và version → merge() được gọi. Với entity đang MANAGED (vừa load bằng findById), merge() trả về chính nó và không chạy SQL nào. Nghĩa là ở đây save() không bắt buộc: Hibernate dirty checking tự phát hiện thay đổi và flush khi commit. Gọi save() chỉ để code rõ ý hơn, nó không làm flush sớm hơn. Muốn đẩy SQL xuống ngay (vd để bắt lỗi constraint trong method) thì dùng saveAndFlush().
# flush: generate & execute update sql
Khi flush (trước commit hoặc explicit):
- Hibernate compare current entity state vs snapshot (state lúc load)
- Detect dirty fields: name changed, price changed
- Generate UPDATE SQL (chỉ update dirty columns nếu
@DynamicUpdate, hoặc tất cả columns mặc định)
-- Generated UPDATE (default: all columns)
UPDATE products
SET name = 'Laptop Pro 2024',
price = 25990000,
code = 'LP-2024', -- unchanged nhưng vẫn trong SET (default behavior)
status = 'ACTIVE', -- unchanged
updated_at = '2024-06-19T10:30:00',
version = 3 -- optimistic lock: version + 1
WHERE id = '550e8400-e29b-41d4-a716-446655440000'
AND version = 2 -- optimistic lock: check old version
-- Với @DynamicUpdate (chỉ dirty columns):
UPDATE products
SET name = 'Laptop Pro 2024',
price = 25990000,
updated_at = '2024-06-19T10:30:00',
version = 3
WHERE id = '550e8400-e29b-41d4-a716-446655440000'
AND version = 2- Execute PreparedStatement
- Check
affected rows:
1→ success0→ Hibernate némStaleObjectStateException(JPA:OptimisticLockException), Spring dịch thànhObjectOptimisticLockingFailureException. Nghĩa là có người khác đã update giữa lúc mình đọc và lúc mình ghi.
Điều kiện AND version = ? chỉ có khi entity có field đánh @Version. Không có nó thì update sau đè update trước mà không ai hay (lost update).
# phase 6: postgresql: từ sql đến disk
# query processing pipeline
Khi PostgreSQL server nhận UPDATE statement

Cho UPDATE cụ thể:
- Index Scan trên
products_pkey(B-tree index trên id column) → tìm row location (page + offset) - Heap access: Read page chứa row từ shared buffers (RAM) hoặc disk
- Row-level lock: Acquire row lock (
FOR NO KEY UPDATEkhi không đổi cột key,FOR UPDATEnếu đổi) trên row, block các UPDATE khác trên cùng row - Kiểm điều kiện WHERE (
AND version = 2): nếu row vừa bị transaction khác sửa, ở mức READ COMMITTED PostgreSQL chờ transaction kia xong rồi đánh giá lại WHERE trên bản mới nhất. Lúc đó version đã là 3, không khớp, UPDATE trả về 0 row
# mvcc (multi-version concurrency control): postgresql's magic
PostgreSQL KHÔNG overwrite data cũ. Nó tạo version mới của row:

UPDATE tạo:
- Old tuple: set xmax = current transaction ID (mark as "expired")
- New tuple: INSERT với xmin = current transaction ID
- Old tuple KHÔNG bị xóa ngay, VACUUM cleanup sau
Đây là MVCC: reader đọc bản cũ nên không bị writer block, writer tạo bản mới nên không block reader.
# wal (write-ahead log): durability guarantee
TRƯỚC KHI modify data page, PostgreSQL ghi WAL record (write-ahead log):
Thứ tự write:
1. Ghi WAL record vào WAL buffer (memory)
2. Modify data page trong shared buffers (memory)
3. Khi COMMIT: flush WAL buffer → WAL file trên disk (fsync)
4. Data pages flush ra disk SAU (async, bởi background writer/checkpointer)
Tại sao WAL trước data?
- WAL sequential write (fast: ~1-2ms fsync)
- Data pages random write (slow: phụ thuộc vị trí trên disk)
- Nếu crash sau WAL write nhưng trước data write → recovery replay WAL → data consistent
- Nếu crash trước WAL write → transaction chưa commit → data stays old version → consistent
WAL Record cho UPDATE:
├── Transaction ID: 12345
├── Table: products (OID: 16384)
├── Block: 42 (page number)
├── Offset: 3 (tuple index within page)
├── New tuple: (header + "Laptop Pro 2024", 25990000, ...)
├── Old tuple: chỉ ghi phần key khi wal_level=logical (cho logical replication)
└── Full page image: cả page 8KB nếu đây là lần sửa đầu tiên sau checkpoint
Timestamp không nằm ở record UPDATE mà ở record COMMIT của transaction.
# index update
Khi row data thay đổi, indexes cũng cần update:
- Không phải HOT update: vì tuple mới nằm ở vị trí mới, PostgreSQL phải chèn entry mới vào mọi index của bảng, kể cả primary key (
idkhông đổi) và các index trên cột không hề thay đổi. Entry cũ không bị xoá ngay, VACUUM dọn sau. - Secondary indexes (nếu có index trên
namehoặcprice): đổi giá trị cột được index thì chắc chắn không HOT được. - GIN indexes (full-text search): thêm entry mới; với
fastupdatemặc định bật, entry đi vào pending list rồi được gộp vào index sau.
HOT (Heap-Only Tuple) Update: Nếu tuple mới vừa trong cùng page VÀ không cột nào có index bị đổi → PostgreSQL dùng HOT update: chỉ ghi heap, không đụng index, tuple cũ trỏ sang tuple mới. Lợi về hiệu năng rất rõ. Muốn tăng tỉ lệ HOT thì để chừa chỗ trong page bằng fillfactor (vd 90) và đừng index những cột hay bị update.
# commit: durability moment
Khi Hibernate call connection.commit():
PostgreSQL COMMIT sequence:
1. Write COMMIT WAL record to WAL buffer
2. Flush WAL buffer to disk (fsync) ← ĐÂY là "durability point"
- Sau fsync thành công: dù server crash, data recoverable từ WAL
- Trước fsync: data có thể mất nếu crash
3. Mark transaction as committed in CLOG (commit log)
4. Release row-level locks
5. Return success to client (JDBC driver)
fsync latency:
- SSD: ~0.1-1ms
- HDD: ~5-15ms (disk rotation)
- Battery-backed write cache: <0.1ms
Đây là lý do SSD quan trọng cho database: COMMIT speed = fsync speed
Bước fsync này áp dụng với synchronous_commit = on (mặc định). Đặt off thì PostgreSQL trả success trước khi WAL xuống disk, commit nhanh hơn hẳn nhưng crash có thể mất vài transaction cuối (dữ liệu vẫn nhất quán, chỉ là mất). Có replica đồng bộ thì commit còn phải chờ standby xác nhận.
# phase 7: mongodb: document update
Nếu service cũng update metadata trong MongoDB (common pattern: relational cho structured data, MongoDB cho flexible/nested metadata):
# mongodb wire protocol
Spring Data MongoDB serialize operation thành BSON (Binary JSON) và gửi qua MongoDB Wire Protocol:
// Spring Data MongoDB operation
documentRepository.updateMetadata(productId, metadata);
// Translated to MongoDB command:
db.product_metadata.updateOne(
{ _id: "550e8400-e29b-41d4-a716-446655440000" },
{ $set: {
"metadata.lastModified": ISODate("2024-06-19T10:30:00Z"),
"metadata.modifiedBy": "user-001",
"metadata.version": 3
}},
{ upsert: false }
)# mongodb server processing
MongoDB Update Pipeline:
1. Router (mongos, nếu sharded) chuyển tới Primary, hoặc Primary nhận trực tiếp
2. Parse BSON command
3. Concurrency ở mức document (WiredTiger optimistic: 2 write đụng cùng document
thì 1 bên gặp WriteConflict và MongoDB tự retry)
4. Find document: query _id index (B-tree)
5. Read document từ WiredTiger cache (in-memory) hoặc disk
6. Apply $set: WiredTiger không ghi đè tại chỗ, nó gắn bản mới vào update chain trong cache
7. Write updated document
8. Update indexes (nếu indexed fields thay đổi)
9. Ghi oplog (replica set) + journal (WAL equivalent)
10. Return success
# wiredtiger storage engine: behind mongodb
WiredTiger (default storage engine từ MongoDB 3.2) khác PostgreSQL:
WiredTiger vs PostgreSQL:
├── Concurrency: Document-level lock (WiredTiger) vs Row-level lock (PG)
├── MVCC: Cả 2 đều có. WiredTiger giữ update chain trong RAM, ghi disk kiểu
│ copy-on-write; PG giữ nhiều bản tuple ngay trong heap, VACUUM dọn sau
├── Compression: Snappy/zlib/zstd (WiredTiger) vs TOAST pglz/lz4 cho giá trị lớn (PG)
├── Cache: WiredTiger max(50% của (RAM - 1GB), 256MB) mặc định
│ vs shared_buffers (PG mặc định 128MB, thường chỉnh lên ~25% RAM)
└── Journal: Checkpoint every 60s + journal mỗi 100ms (WiredTiger) vs WAL continuous (PG)
Update in WiredTiger:
- Đọc document vào WiredTiger cache (decompressed)
- Modify document trong cache
- Ghi journal record (durability: mỗi 100ms hoặc mỗi commit nếu
j:true) - Document stays trong cache (dirty page)
- Checkpoint (mỗi 60s): flush dirty pages ra disk (compressed)
# write concern: durability trade-off
MongoDB cho phép tuning durability vs speed:
// Spring Data MongoDB — configure write concern
@Configuration
public class MongoConfig {
@Bean
public MongoClientSettings mongoSettings() {
return MongoClientSettings.builder()
.writeConcern(WriteConcern.MAJORITY // Wait majority replicas acknowledge
.withJournal(true) // Wait journal flush
.withWTimeout(5000, TimeUnit.MILLISECONDS))
.build();
}
}| Write Concern | Durability | Speed | Risk |
|---|---|---|---|
| w:0 (unacknowledged) | None | Fastest | Data loss on any failure |
| w:1 | Primary only | Fast | Data loss if primary crash before replication |
| w:majority (default) | Majority replicas | Medium | Safe (tolerates minority failures) |
| w:1 + j:true | Primary + journal | Medium | Safe on single node (journal recovery) |
| w:majority + j:true | Majority + journal | Slowest | Safest |
Từ MongoDB 5.0, write concern mặc định là w:majority (trừ vài cấu hình có arbiter), không còn là w:1 như bản cũ. Và vì writeConcernMajorityJournalDefault mặc định là true, w:majority đã ngầm chờ journal trên các node đó, nên hai dòng cuối trong thực tế gần như tương đương. Nguồn: MongoDB docs về default write concern.
# phase 8: response journey: back to client
# service → controller → response serialization
// Service returns DTO
ProductDTO updated = toDTO(saved);
// Controller wraps in APIResponse
return ResponseEntity.ok(APIResponse.success(updated));
// Spring MVC response handling:
// 1. HandlerMethodReturnValueHandler detect return type = ResponseEntity
// 2. Extract status (200), headers, body (APIResponse<ProductDTO>)
// 3. Content negotiation: Accept header = "application/json" → Jackson
// 4. Jackson ObjectMapper.writeValueAsString(apiResponse) → JSON string
// 5. Write JSON bytes to HttpServletResponse OutputStreamJackson serialization:
// ObjectMapper traverses object graph:
APIResponse
├── statusCode: 200 → "status_code": 200
├── data: ProductDTO
│ ├── id: UUID → "id": "550e8400-..."
│ ├── name: "Laptop Pro 2024" → "name": "Laptop Pro 2024"
│ ├── price: BigDecimal → "price": 25990000
│ ├── updatedAt: LocalDateTime → "updated_at": "2024-06-19T10:30:00" (via JavaTimeModule)
│ └── null fields → SKIPPED (JsonInclude.NON_NULL)
└── timestamp: Instant → "timestamp": "2024-06-19T03:30:00.123Z"
// Output ~200 bytes JSONKết quả trên giả định ObjectMapper đã được cấu hình: status_code, updated_at dạng snake_case cần PropertyNamingStrategies.SNAKE_CASE (hoặc spring.jackson.property-naming-strategy), còn bỏ field null cần @JsonInclude(NON_NULL) hoặc spring.jackson.default-property-inclusion=non_null. Mặc định Jackson giữ tên camelCase và vẫn ghi null.
# response filters (outbound)
Response đi ngược qua filter chain:
- Response logging filter: log response status, size, duration
- Metrics filter: record request duration → Prometheus
http_server_requests_seconds - Security headers: add
X-Content-Type-Options: nosniff,X-Frame-Options: DENY - Compression: nếu client Accept-Encoding: gzip → compress response body. Với Spring Boot, việc này do Tomcat connector làm khi bật
server.compression.enabled=true(mặc định tắt), chứ không phải một filter.
Một chi tiết hay bị bỏ qua: header phải được gắn trước khi response bị commit (lúc byte đầu tiên của body ra socket). Vì vậy filter như HeaderWriterFilter của Spring Security gắn header ngay từ đầu hoặc ngay trước lúc commit, chứ đợi body ghi xong mới thêm header thì đã muộn.
# tomcat → network → client
1. Tomcat write response to socket buffer
2. TCP send: split into segments (MSS ~1460 bytes), send with ACK
3. TLS encrypt (nếu không termination ở LB)
4. Load Balancer forward to client
5. Client receive → parse JSON → update UI
HTTP Response:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 287
X-Request-Id: req-7f3a2b1c-4d5e-6f7a
X-Response-Time: 45ms
{"status_code":200,"data":{"id":"550e8400-...","name":"Laptop Pro 2024","price":25990000,"updated_at":"2024-06-19T10:30:00"},"timestamp":"2024-06-19T03:30:00.123Z"}
# phase 9: after response: async & background
Sau khi response đã gửi, nhiều thứ vẫn xảy ra:
# event publishing (spring events)
// Trong service, event đã được publish:
eventPublisher.publishEvent(new ProductUpdatedEvent(product.getId(), oldName, newName));
// @TransactionalEventListener chạy SAU commit:
@Async // cần @EnableAsync; thiếu @Async thì listener chạy đồng bộ trên chính worker thread
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductUpdated(ProductUpdatedEvent event) {
// Nhờ @Async: chạy trên thread khác, không block response
cacheService.evict("product:" + event.getProductId());
searchIndexService.reindex(event.getProductId());
auditService.logChange(event);
webhookService.notify(event);
}Cần nói rõ: AFTER_COMMIT chỉ có nghĩa là "sau khi transaction commit", mà commit xảy ra lúc service method return, tức là trước khi response được serialize và gửi đi. Không có @Async thì cả 4 việc trên chạy tuần tự trên worker thread và client phải chờ hết. Có @Async thì chúng chạy song song với phần trả response. Đổi lại, event nằm trong memory: app crash ngay sau commit là mất, nên việc nào bắt buộc phải xảy ra (audit, webhook) thì nên đi qua outbox.
# postgresql background processes
Sau COMMIT, PostgreSQL background workers:
├── WAL Sender: replicate WAL records to standby servers (streaming replication)
├── Background Writer: periodically flush dirty pages from shared buffers to disk
├── Checkpointer: every 5 min (checkpoint_timeout) hoặc khi WAL chạm max_wal_size
├── Autovacuum: later, clean dead tuples left by UPDATE (MVCC)
│ - Remove old row version (xmax set)
│ - Update visibility map
│ - Autoanalyze: update statistics for query planner
└── Cumulative stats: pg_stat_user_tables (seq_scan, idx_scan, n_tup_upd)
(PG 15 bỏ process Stats Collector, số liệu giữ trong shared memory)
# connection return to pool
Sau transaction commit:
1. connection.setAutoCommit(true) — restore default
2. Reset connection state (warnings, savepoints)
3. Return connection to HikariCP pool → available for next request
4. Worker thread return to Tomcat pool → available for next request
Timeline of thread/connection usage:
├── Thread occupied: ~40ms (từ lúc vào filter tới lúc ghi xong socket)
├── Connection occupied: ~25ms (from TX begin to TX commit)
└── Actual DB processing: ~5ms (query + update + commit)
# timeline summary: ~50ms breakdown
Các mốc dưới đây là số minh hoạ cho một request "đẹp" trong cùng region, không phải số đo thật. Hệ thống của bạn sẽ khác, nhất là phần network.
0ms ─── Client sends request
2ms ─── TLS/TCP (reused connection)
5ms ─── Load Balancer → App Server
8ms ─── Servlet Filters (JWT validation: ~3ms)
12ms ─── Argument resolution + validation
15ms ─── Service method enter, TX begin, get connection from pool
18ms ─── SELECT (findById): index scan + return (~3ms)
20ms ─── Business logic (set fields): ~0.1ms
22ms ─── Dirty checking + flush: generate UPDATE SQL
25ms ─── PostgreSQL UPDATE execution: index scan + row lock + write + WAL
30ms ─── MongoDB update: find + modify + journal
35ms ─── COMMIT PostgreSQL: WAL fsync (~1-2ms SSD)
40ms ─── Release connection, TX complete
42ms ─── DTO mapping + Jackson serialization
45ms ─── Response filters (logging, metrics)
47ms ─── Tomcat write to socket
50ms ─── Network transit back to client
52ms ─── Client receives response
After response (async):
60ms ─── Cache eviction
70ms ─── Search re-index
80ms ─── Audit log
100ms ─── Webhook notification
... ─── PostgreSQL autovacuum (minutes later)
# điều gì có thể sai? (failure modes)
| Layer | Failure | Impact | Mitigation |
|---|---|---|---|
| Network | Timeout, packet loss | Client gets timeout error | Retry with idempotency key |
| Load Balancer | All backends unhealthy | 503 Service Unavailable | Health checks, auto-scaling |
| Tomcat | Thread pool exhausted | Connection queue full → rejected | Monitor threads, tune pool |
| Security | Token expired | 401 Unauthorized | Client refresh token flow |
| Validation | Invalid input | 400 Bad Request | Clear error messages |
| Connection Pool | Pool exhausted | Thread waits → timeout | Monitor pool, tune max size |
| Database | Deadlock | Transaction rollback → retry | Consistent lock ordering |
| Database | Optimistic Lock | Update rejected (concurrent edit) | Retry or notify user |
| PostgreSQL | Disk full | All writes fail | Monitoring, auto-extend, alerts |
| MongoDB | Primary election | Brief write unavailability | retryWrites=true, write concern |
| Network (internal) | Service-to-DB connection lost | Transaction rollback | Connection validation, retry |
# key takeaways
- 1 HTTP request touches 10+ components: mỗi cái có thể fail independently
- Thread is precious: mỗi request "chiếm" 1 thread suốt lifecycle. Connection cũng vậy. Pool sizes = throughput limit.
- Flush ≠ Commit: Hibernate flush gửi SQL đến DB nhưng chưa durable. Commit + WAL fsync = durable.
- PostgreSQL MVCC: UPDATE = INSERT new + mark old dead. VACUUM cleanup sau. Đây là lý do table bloat nếu VACUUM không chạy đủ.
- MongoDB document-level concurrency và PostgreSQL row-level lock đều cho phép ghi song song trên các document/row khác nhau; ghi cùng một document/row thì vẫn phải lần lượt.
- Connection pool size là bottleneck rất hay gặp. Công thức khởi điểm trong tài liệu "About Pool Sizing" của HikariCP: pool size = ((core_count * 2) + effective_spindle_count), với core_count là số core của DB server. Nhớ nhân với số instance app đang chạy, rồi đo tải thật để chỉnh.
- Phần lớn latency của kiểu request CRUD này nằm ở I/O: network round-trips, disk fsync, chờ connection. Phần CPU (set field, serialize JSON) thường chỉ chiếm phần nhỏ.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.