Bối cảnh & Tại sao chọn Stack này
2:47 sáng, cảnh báo xuất hiện. Microservice Node.js của chúng tôi liên tục trả về lỗi 503 dưới tải cao — connection pool đến PostgreSQL đã bão hòa hoàn toàn. Đến 4 giờ sáng, chúng tôi đưa ra quyết định: di chuyển billing service quan trọng sang Spring Boot. Không phải vì Java đang thịnh hành, mà vì chúng tôi cần threading có thể dự đoán được, xử lý kết nối không bị sụp đổ khi có spike concurrency, và một tầng bảo mật không phải tự xây dựng từ đầu.
Đó là mười tám tháng trước. Kể từ đó, billing service chưa một lần gặp tình trạng pool exhaustion. Hàng triệu giao dịch, không một sự cố nào.
Spring Boot kết hợp JPA và Spring Security mang lại ba thứ quan trọng lúc 2 giờ sáng:
- Spring Security — JWT auth, bảo vệ CSRF và phân quyền theo role mà không cần tự viết logic bảo mật từ đầu
- Spring Data JPA — Trừu tượng hóa database với connection pooling tích hợp sẵn; ngăn chặn thảm họa N+1 query làm bão hòa kết nối dưới tải cao
- Docker — Triển khai nhất quán để “chạy được trên máy tôi” không còn là lý do biện hộ lúc 4 giờ sáng
Cài đặt
Điều kiện tiên quyết
Bạn cần Java 17+, Maven 3.8+, Docker và PostgreSQL. Dùng SDKMAN để quản lý phiên bản Java — chuyển đổi JDK giữa chừng mà không có nó thật sự khổ sở:
# Cài đặt SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Cài đặt Java 17
sdk install java 17.0.9-tem
java -version
Khởi tạo dự án
Dùng Spring Initializr từ CLI — nhanh hơn web UI khi bạn phải khởi tạo lúc nửa đêm:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=3.2.0 \
-d baseDir=my-api \
-d groupId=com.example \
-d artifactId=my-api \
-d dependencies=web,data-jpa,security,postgresql,lombok,validation \
-o my-api.zip && unzip my-api.zip
Lệnh trên tạo ra file pom.xml với bốn dependency thực hiện những công việc nặng nhọc nhất:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Cấu hình
Thiết lập Database & JPA
Cấu hình application.yml — dùng YAML thay vì .properties vì indent sai dễ nhìn ra hơn dấu chấm bị thiếu lúc 2 giờ sáng:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:secret}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # KHÔNG BAO GIỜ dùng create-drop trong production
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
Các cấu hình HikariCP pool không phải ngẫu nhiên. PostgreSQL mặc định tối đa 100 kết nối. Chạy hai instance ứng dụng với mỗi instance 20 kết nối là đã dùng hết 40 — còn lại 60 cho DBA, các query monitoring và phiên admin khẩn cấp không thể tránh khỏi. Vượt ngưỡng đó, mọi kết nối mới sẽ bị chặn.
Entity, Repository và Controller
// Product.java
@Entity
@Table(name = "products")
@Data
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
@NotBlank(message = "Tên sản phẩm là bắt buộc")
private String name;
@Column(nullable = false)
@DecimalMin(value = "0.0", message = "Giá phải là số dương")
private BigDecimal price;
@CreationTimestamp
private LocalDateTime createdAt;
}
// ProductRepository.java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByNameContainingIgnoreCase(String name);
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
List<Product> findByPriceRange(@Param("min") BigDecimal min,
@Param("max") BigDecimal max);
}
// ProductController.java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Validated
public class ProductController {
private final ProductRepository productRepository;
@GetMapping
public ResponseEntity<List<Product>> getAll() {
return ResponseEntity.ok(productRepository.findAll());
}
@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody Product product) {
Product saved = productRepository.save(product);
URI location = URI.create("/api/v1/products/" + saved.getId());
return ResponseEntity.created(location).body(saved);
}
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
return productRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!productRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
productRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Cấu hình Spring Security
Spring Security 6 (Boot 3.x) đã bỏ WebSecurityConfigurerAdapter. Nếu copy code từ Stack Overflow trước năm 2023, code sẽ compile được nhưng cấu hình sai mà không có cảnh báo. Đây là cách tiếp cận dựa trên component thực sự hoạt động:
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // REST stateless — không cần CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("secret"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
}
Docker hóa ứng dụng
Multi-stage build giảm kích thước image từ ~500MB xuống ~150MB — sự khác biệt này quan trọng khi phải pull image qua mạng chập chờn lúc nửa đêm. Bước dependency:go-offline đáng để tạo thêm một cache layer: chỉ thay đổi code ứng dụng thì Docker bỏ qua hoàn toàn việc download Maven trong lần build tiếp theo, tiết kiệm 2–3 phút mỗi lần lặp:
# Dockerfile
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn clean package -DskipTests -q
# Runtime stage — image nhỏ hơn, không có JDK overhead
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8080:8080"
environment:
DB_USERNAME: postgres
DB_PASSWORD: secret
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
Xác minh & Giám sát
Chạy và kiểm tra API
# Khởi động tất cả
docker-compose up -d
# Theo dõi log khởi động
docker-compose logs -f api
# Kiểm tra endpoint công khai
curl http://localhost:8080/api/v1/products
# Tạo sản phẩm (yêu cầu Basic Auth)
curl -X POST http://localhost:8080/api/v1/products \
-u admin:secret \
-H "Content-Type: application/json" \
-d '{"name": "Hướng dẫn PostgreSQL", "price": 29.99}'
# Trả về: 201 Created với Location: /api/v1/products/1
Spring Actuator để giám sát sức khỏe
Thêm dependency Actuator và expose các endpoint — đây là thứ đã cứu chúng tôi trong một sự cố lúc nửa đêm khi cần xem trạng thái connection pool mà không có SSH access:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# Thêm vào application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
# Kiểm tra sức khỏe ứng dụng
curl http://localhost:8080/actuator/health
# Phản hồi:
# {
# "status": "UP",
# "components": {
# "db": {"status": "UP"},
# "diskSpace": {"status": "UP"}
# }
# }
# Kiểm tra metrics HikariCP pool
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
Những gì cần theo dõi trong Production
Sau khi áp dụng pattern này cho ba service, đây là các metric thực sự kích hoạt cảnh báo:
- HikariCP active connections — cảnh báo khi liên tục vượt 80%
maximum-pool-size - JVM heap usage — ứng dụng Spring Boot thường ổn định ở mức 200–400MB; spike thường có nghĩa là query trả về quá nhiều hàng
- PostgreSQL slow queries — đặt
log_min_duration_statement = 1000trongpostgresql.confđể bắt các query chậm hơn 1 giây - Response time percentiles — thêm Micrometer + Prometheus để theo dõi p99; giá trị trung bình che khuất độ trễ đuôi mà người dùng thực sự cảm nhận
Có một điều tôi muốn nhắn nhủ với bản thân trong quá khứ: đặt ddl-auto: validate từ ngày đầu tiên, không phải create hay update. Schema management của Hibernate không có rollback. Một migration thất bại giữa chừng khi deploy để lại bạn phải khôi phục thủ công vào thời điểm tệ nhất có thể. Khi vượt qua giai đoạn một developer duy nhất, hãy chuyển sang Flyway cho versioned migration. Setup mất hai giờ. Mọi lần deploy production thành công sau đó chính là thành quả xứng đáng.
