ShoppingCart-dev-009-high-concurrency - Part 2#228
Conversation
Approach 4 — Redisson distributed lock: - RedissonConfig: single-server Redisson client bean - CartService.addToCart: per-user lock (cart:user:<id>) - OrderService.placeOrder: per-user lock (order:user:<id>) prevents duplicate orders Approach 5 — Read replica routing: - DataSourceConfig: AbstractRoutingDataSource routes read-only txns to replica - Enabled via app.datasource.replica.enabled=true (off by default) - ProductService, CategoryService: @transactional(readOnly=true) on read methods Approach 7 — Horizontal scaling: - docker-compose-ha.yml: multi-replica app behind Nginx (--scale app=N) - nginx/nginx.conf: least_conn upstream, health endpoint - Actuator health endpoint exposed for load-balancer probes All 87 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces distributed locking using Redisson, read-replica routing, and horizontal scaling with Nginx. Key changes include the implementation of a RoutingDataSource for database read/write splitting and the application of distributed locks in the CartService and OrderService to handle concurrency. Review feedback identifies critical issues regarding the read-replica routing logic, specifically the requirement to wrap the data source in a LazyConnectionDataSourceProxy and the need to use Spring's @transactional annotation instead of the Jakarta version. Additionally, feedback was provided to ensure atomicity and idempotency during the order placement process.
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.context.annotation.Primary; | ||
| import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; | ||
| import org.springframework.transaction.support.TransactionSynchronizationManager; |
There was a problem hiding this comment.
To support read-replica routing based on the @Transactional(readOnly = true) attribute, you should add the import for LazyConnectionDataSourceProxy.
| import org.springframework.transaction.support.TransactionSynchronizationManager; | |
| import org.springframework.transaction.support.TransactionSynchronizationManager; | |
| import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; |
| @Primary | ||
| @Bean | ||
| public DataSource dataSource( | ||
| @Qualifier("primaryDataSource") DataSource primary, | ||
| @Qualifier("replicaDataSource") DataSource replica) { | ||
|
|
||
| RoutingDataSource routing = new RoutingDataSource(); | ||
|
|
||
| Map<Object, Object> targets = new HashMap<>(); | ||
| targets.put("primary", primary); | ||
| targets.put("replica", replica); | ||
| routing.setTargetDataSources(targets); | ||
| routing.setDefaultTargetDataSource(primary); | ||
| return routing; | ||
| } |
There was a problem hiding this comment.
The RoutingDataSource must be wrapped in a LazyConnectionDataSourceProxy. Without this, Spring's transaction manager will fetch a connection from the data source before the transaction synchronization (and thus the read-only status) is initialized, causing the routing to always default to the primary data source.
| @Primary | |
| @Bean | |
| public DataSource dataSource( | |
| @Qualifier("primaryDataSource") DataSource primary, | |
| @Qualifier("replicaDataSource") DataSource replica) { | |
| RoutingDataSource routing = new RoutingDataSource(); | |
| Map<Object, Object> targets = new HashMap<>(); | |
| targets.put("primary", primary); | |
| targets.put("replica", replica); | |
| routing.setTargetDataSources(targets); | |
| routing.setDefaultTargetDataSource(primary); | |
| return routing; | |
| } | |
| @Primary | |
| @Bean | |
| public DataSource dataSource( | |
| @Qualifier("primaryDataSource") DataSource primary, | |
| @Qualifier("replicaDataSource") DataSource replica) { | |
| RoutingDataSource routing = new RoutingDataSource(); | |
| Map<Object, Object> targets = new HashMap<>(); | |
| targets.put("primary", primary); | |
| targets.put("replica", replica); | |
| routing.setTargetDataSources(targets); | |
| routing.setDefaultTargetDataSource(primary); | |
| return new LazyConnectionDataSourceProxy(routing); | |
| } |
| public void placeOrder(User user, String sessionId) { | ||
|
|
||
| // first get user's cart items | ||
| CartDto cartDto = cartService.listCartItems(user); | ||
| List<CartItemDto> cartItemDtoList = cartDto.getCartItems(); | ||
|
|
||
| // TODO : need to wrap with try-catch ? | ||
| // create order and save | ||
| Order newOrder = new Order(); | ||
| newOrder.setCreatedDate(new Date()); | ||
| newOrder.setSessionId(sessionId); | ||
| newOrder.setUser(user); | ||
| newOrder.setTotalPrice(cartDto.getTotalCost()); | ||
| orderRepository.save(newOrder); | ||
|
|
||
| for (CartItemDto cartItemDto : cartItemDtoList) { | ||
| // create orderItem and save each one | ||
| OrderItem orderItem = new OrderItem(); | ||
| orderItem.setCreatedDate(new Date()); | ||
| orderItem.setPrice(cartItemDto.getProduct().getPrice()); | ||
| orderItem.setProduct(cartItemDto.getProduct()); | ||
| orderItem.setQuantity(cartItemDto.getQuantity()); | ||
| orderItem.setOrder(newOrder); | ||
| // add to order item list | ||
| orderItemsRepository.save(orderItem); | ||
| RLock lock = redissonClient.getLock("order:user:" + user.getId()); | ||
| try { | ||
| lock.lock(30, TimeUnit.SECONDS); | ||
|
|
||
| CartDto cartDto = cartService.listCartItems(user); | ||
| List<CartItemDto> cartItemDtoList = cartDto.getCartItems(); | ||
|
|
||
| Order newOrder = new Order(); | ||
| newOrder.setCreatedDate(new Date()); | ||
| newOrder.setSessionId(sessionId); | ||
| newOrder.setUser(user); | ||
| newOrder.setTotalPrice(cartDto.getTotalCost()); | ||
| orderRepository.save(newOrder); | ||
|
|
||
| for (CartItemDto cartItemDto : cartItemDtoList) { | ||
| OrderItem orderItem = new OrderItem(); | ||
| orderItem.setCreatedDate(new Date()); | ||
| orderItem.setPrice(cartItemDto.getProduct().getPrice()); | ||
| orderItem.setProduct(cartItemDto.getProduct()); | ||
| orderItem.setQuantity(cartItemDto.getQuantity()); | ||
| orderItem.setOrder(newOrder); | ||
| orderItemsRepository.save(orderItem); | ||
| } | ||
|
|
||
| cartService.deleteUserCartItems(user); | ||
| } finally { | ||
| if (lock.isHeldByCurrentThread()) lock.unlock(); | ||
| } | ||
|
|
||
| // delete items in cart, since they are already transformed to orders | ||
| cartService.deleteUserCartItems(user); | ||
| } |
There was a problem hiding this comment.
The placeOrder method lacks atomicity. It performs multiple database writes (saving the order, saving multiple order items, and deleting cart items) which must be wrapped in a transaction to ensure consistency. Additionally, when using a distributed lock for idempotency, you should check if the cart is empty after acquiring the lock to avoid creating duplicate empty orders if a previous request already processed the cart.
Note: Be aware that the lock should ideally wrap the transaction scope to prevent race conditions between the unlock and the transaction commit.
@Transactional
public void placeOrder(User user, String sessionId) {
RLock lock = redissonClient.getLock("order:user:" + user.getId());
try {
lock.lock(30, TimeUnit.SECONDS);
CartDto cartDto = cartService.listCartItems(user);
List<CartItemDto> cartItemDtoList = cartDto.getCartItems();
if (cartItemDtoList.isEmpty()) {
return;
}
Order newOrder = new Order();
newOrder.setCreatedDate(new Date());
newOrder.setSessionId(sessionId);
newOrder.setUser(user);
newOrder.setTotalPrice(cartDto.getTotalCost());
orderRepository.save(newOrder);
for (CartItemDto cartItemDto : cartItemDtoList) {
OrderItem orderItem = new OrderItem();
orderItem.setCreatedDate(new Date());
orderItem.setPrice(cartItemDto.getProduct().getPrice());
orderItem.setProduct(cartItemDto.getProduct());
orderItem.setQuantity(cartItemDto.getQuantity());
orderItem.setOrder(newOrder);
orderItemsRepository.save(orderItem);
}
cartService.deleteUserCartItems(user);
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}| import org.redisson.api.RLock; | ||
| import org.redisson.api.RedissonClient; |
There was a problem hiding this comment.
To support the read-replica routing feature (Approach 5), you should use org.springframework.transaction.annotation.Transactional instead of jakarta.transaction.Transactional, as the latter does not support the readOnly attribute used by the routing logic.
| import org.redisson.api.RLock; | |
| import org.redisson.api.RedissonClient; | |
| import org.redisson.api.RLock; | |
| import org.redisson.api.RedissonClient; | |
| import org.springframework.transaction.annotation.Transactional; |
Tests (18 new, 105 total): - RedissonConfigTest: validates Config address/pool wiring without connecting to Redis - DataSourceRoutingTest: verifies RoutingDataSource key logic via TransactionSynchronizationManager flag - CartServiceLockTest: lock key scoping, lock-before-save order, unlock in finally, per-user independence - OrderServiceLockTest: lock-before-save, entire pipeline inside lock, session ID saved correctly - OrderServiceTest: add RedissonClient mock so existing tests compile with new dependency Docs: - doc/high_concurrency_engineering.md: sr-engineer-level deep dive covering pool math, Virtual Thread JDBC interaction, deserialization RCE risk, Redlock lease vs watchdog, replication lag trade-offs, routing/caching AOP order, and what was intentionally omitted - README.md: update dependency list, HA docker-compose usage, quick-start snippets for Virtual Threads and read replica, updated API table with /actuator/health Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…al scaling, read replica Backend Dockerfile (multi-stage build): - Stage 1: maven:3.9-eclipse-temurin-17 builds the JAR (dependency layer cached separately) - Stage 2: eclipse-temurin:17-jre-alpine runtime only (~200MB vs ~500MB, no Maven/source) - Non-root spring user, container-aware JVM flags (MaxRAMPercentage=75) - Startup time: 3+ min (mvn run) → ~15s (java -jar) application-docker.properties: - Activated via SPRING_PROFILES_ACTIVE=docker - Overrides datasource/redis hostnames to Docker service names (mysql, redis) docker-compose.yml (dev): - Added Redis service with AOF persistence and healthcheck - MySQL upgraded to 8.0 with utf8mb4 and max_connections=200 - App waits for mysql+redis via depends_on condition:service_healthy - Actuator /health as app healthcheck; all services have healthchecks docker-compose-ha.yml (HA): - App uses built JAR image (not mvn run), no port binding (Nginx only) - MySQL primary (server-id=1, GTID, binlog) + replica (server-id=2, read-only=ON) - primary-init.sql creates replication user; replica-init.sh waits for primary then CHANGE MASTER TO - Redis with maxmemory=256mb allkeys-lru eviction policy - Per-instance pool size=20 (3 replicas × 20 = 60 < MySQL max_connections=200) - APP_DATASOURCE_REPLICA_ENABLED env var toggles read-replica routing (Approach 5) nginx/nginx.conf: - worker_processes auto (one per CPU core), epoll, multi_accept - keepalive 32 to app upstream (avoids TCP handshake per request) - Rate limiting: 100 req/s per IP, burst 200 - proxy_next_upstream for fast failover on 502/503 Frontend Dockerfile: Node 14 → 20, CMD properly sets --host 0.0.0.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CI (.github/workflows/docker_shopCartService.yml):
Job 1 - unit-tests: fast Maven test gate, uploads surefire reports on failure
Job 2 - docker-build: multi-stage build via docker/build-push-action with GHA layer cache;
verifies non-root 'spring' user in final image
Job 3 - integration: starts mysql+redis, waits for service_healthy via docker inspect,
starts app, waits for actuator/health UP, then runs 5 smoke tests:
GET /product/, GET /category/, GET /actuator/health,
POST /user/register, POST /user/signin
always tears down with 'docker compose down -v' (clean volumes between runs)
Paths filter: only runs on ShoppingCart/** changes
Backend/Dockerfile: add 'apk add wget' in runtime stage for healthcheck compatibility
doc/container_architecture.md: sr-engineer-level doc covering:
- Dev vs HA stack topology diagrams
- Multi-stage build rationale (layer caching, image size, startup time)
- JVM flags explained (UseContainerSupport, MaxRAMPercentage, security.egd)
- Service dependency and health chain
- MySQL GTID replication wiring and init script timing
- Nginx least_conn, keepalive, rate limiting
- Spring Boot docker profile and env var relaxed binding
- CI three-job design rationale and Docker layer caching in CI
- Operating runbook (start/stop/scale/verify replication/check locks)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
================ 2 ================ |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request significantly hardens the application for high-concurrency workloads by introducing a multi-stage Docker architecture, horizontal scaling via Nginx, and Redis-based distributed locking for critical sections in the cart and order services. It also implements read-replica routing for database operations and updates the stack to Spring Boot 3.2 and Java 17. Review feedback identifies several critical improvements: the placeOrder method requires @Transactional for atomicity, distributed locks should ideally use tryLock with timeouts to prevent thread exhaustion, and the Redisson connection pool size should be increased to handle higher loads. Additionally, it is recommended to replace hardcoded data source routing keys with constants for better maintainability.
| cartRepository.save(cart); | ||
| RLock lock = redissonClient.getLock("cart:user:" + user.getId()); | ||
| try { | ||
| lock.lock(10, TimeUnit.SECONDS); |
There was a problem hiding this comment.
Using lock.lock(10, TimeUnit.SECONDS) is a blocking operation that waits indefinitely for the lock to become available. In a high-concurrency system, this can lead to thread pool exhaustion if Redis latency increases or contention is high. It is generally safer to use tryLock with a timeout to fail fast and avoid hanging threads. Note that tryLock throws a checked InterruptedException which would need to be handled.
| public void placeOrder(User user, String sessionId) { | ||
|
|
||
| // first get user's cart items | ||
| CartDto cartDto = cartService.listCartItems(user); | ||
| List<CartItemDto> cartItemDtoList = cartDto.getCartItems(); | ||
|
|
||
| // TODO : need to wrap with try-catch ? | ||
| // create order and save | ||
| Order newOrder = new Order(); | ||
| newOrder.setCreatedDate(new Date()); | ||
| newOrder.setSessionId(sessionId); | ||
| newOrder.setUser(user); | ||
| newOrder.setTotalPrice(cartDto.getTotalCost()); | ||
| orderRepository.save(newOrder); | ||
|
|
||
| for (CartItemDto cartItemDto : cartItemDtoList) { | ||
| // create orderItem and save each one | ||
| OrderItem orderItem = new OrderItem(); | ||
| orderItem.setCreatedDate(new Date()); | ||
| orderItem.setPrice(cartItemDto.getProduct().getPrice()); | ||
| orderItem.setProduct(cartItemDto.getProduct()); | ||
| orderItem.setQuantity(cartItemDto.getQuantity()); | ||
| orderItem.setOrder(newOrder); | ||
| // add to order item list | ||
| orderItemsRepository.save(orderItem); | ||
| RLock lock = redissonClient.getLock("order:user:" + user.getId()); | ||
| try { | ||
| lock.lock(30, TimeUnit.SECONDS); | ||
|
|
||
| CartDto cartDto = cartService.listCartItems(user); | ||
| List<CartItemDto> cartItemDtoList = cartDto.getCartItems(); | ||
|
|
||
| Order newOrder = new Order(); | ||
| newOrder.setCreatedDate(new Date()); | ||
| newOrder.setSessionId(sessionId); | ||
| newOrder.setUser(user); | ||
| newOrder.setTotalPrice(cartDto.getTotalCost()); | ||
| orderRepository.save(newOrder); | ||
|
|
||
| for (CartItemDto cartItemDto : cartItemDtoList) { | ||
| OrderItem orderItem = new OrderItem(); | ||
| orderItem.setCreatedDate(new Date()); | ||
| orderItem.setPrice(cartItemDto.getProduct().getPrice()); | ||
| orderItem.setProduct(cartItemDto.getProduct()); | ||
| orderItem.setQuantity(cartItemDto.getQuantity()); | ||
| orderItem.setOrder(newOrder); | ||
| orderItemsRepository.save(orderItem); | ||
| } | ||
|
|
||
| cartService.deleteUserCartItems(user); | ||
| } finally { | ||
| if (lock.isHeldByCurrentThread()) lock.unlock(); | ||
| } | ||
|
|
||
| // delete items in cart, since they are already transformed to orders | ||
| cartService.deleteUserCartItems(user); | ||
| } |
There was a problem hiding this comment.
The placeOrder method is missing the @Transactional annotation. Since it performs multiple database operations across different repositories (orderRepository, orderItemsRepository) and services (cartService), it must be atomic to prevent partial data persistence in case of failure.
Additionally, when combining distributed locks with transactions, the lock should be acquired before the transaction starts and released after the transaction commits. If you annotate this method with @Transactional, the transaction will start before the lock is acquired (due to Spring's AOP proxy), which can lead to race conditions where a second thread acquires the lock before the first thread's changes are committed to the database. Consider splitting this into a non-transactional wrapper that handles the lock and calls a separate transactional method for the business logic.
| targets.put("primary", primary); | ||
| targets.put("replica", replica); | ||
| routing.setTargetDataSources(targets); | ||
| routing.setDefaultTargetDataSource(primary); | ||
| return routing; | ||
| } | ||
|
|
||
| // Routes read-only transactions to the replica, writes to primary. | ||
| static class RoutingDataSource extends AbstractRoutingDataSource { | ||
| @Override | ||
| protected Object determineCurrentLookupKey() { | ||
| return TransactionSynchronizationManager.isCurrentTransactionReadOnly() | ||
| ? "replica" | ||
| : "primary"; |
| config.useSingleServer() | ||
| .setAddress("redis://" + redisHost + ":" + redisPort) | ||
| .setConnectionMinimumIdleSize(2) | ||
| .setConnectionPoolSize(10); |
There was a problem hiding this comment.
A connection pool size of 10 is likely too low for a high-concurrency application, especially when distributed locks are used frequently. This could become a bottleneck under heavy load as threads wait for available Redis connections.
| .setConnectionPoolSize(10); | |
| .setConnectionPoolSize(64); |
…ocker daemon docker/build-push-action with push:false uses BuildKit but does NOT load the image into the local Docker daemon by default. docker image inspect and docker-compose both need the image in the daemon. load:true fixes this. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
COMPOSE_FILE=ShoppingCart/docker-compose.yml combined with working-directory: ShoppingCart made Docker Compose resolve ShoppingCart/ShoppingCart/docker-compose.yml. The working-directory already puts the shell in the right place; COMPOSE_FILE is redundant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Actual endpoints are /user/signup and /user/signIn (camelCase), not /user/register and /user/signin — both returned 404. Verified against UserController @PostMapping annotations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…transactional, pool size DataSourceConfig (HIGH): - Wrap RoutingDataSource in LazyConnectionDataSourceProxy so connection acquisition is deferred until first SQL — at which point @transactional has already set the read-only flag, making replica routing work correctly - Extract "primary"/"replica" as KEY_PRIMARY/KEY_REPLICA constants (MEDIUM) - Call routing.afterPropertiesSet() before wrapping CartService (HIGH + MEDIUM): - lock() → tryLock(waitTime=3s, leaseTime=10s): fail fast instead of blocking indefinitely, prevents thread-pool exhaustion under Redis latency spikes - jakarta.transaction.Transactional → org.springframework.transaction.annotation.Transactional (Spring's annotation supports readOnly, jakarta's does not) OrderService (HIGH + HIGH + MEDIUM): - Split placeOrder into non-@transactional lock wrapper + @transactional doPlaceOrder so the transaction commits before the lock is released (correct ordering) - Added empty-cart idempotency guard in doPlaceOrder: if cart is empty after lock acquisition, skip order creation (handles duplicate/concurrent checkout requests) - tryLock(waitTime=3s, leaseTime=30s) replaces blocking lock() - org.springframework.transaction.annotation.Transactional replaces jakarta version - @transactional(readOnly=true) on listOrders and getOrder RedissonConfig (MEDIUM): - connectionPoolSize 10 → 64 (prevents Redis pool bottleneck under high concurrency) - connectionMinimumIdleSize 2 → 4 Tests: updated CartServiceLockTest, OrderServiceLockTest, CartServiceTest, OrderServiceTest to mock tryLock instead of lock; added tryLock-failure and empty-cart tests (107 total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Approach 4 — Redisson distributed lock:
Approach 5 — Read replica routing:
Approach 7 — Horizontal scaling:
All 87 tests pass.