Skip to content

ShoppingCart-dev-009-high-concurrency - Part 2#228

Open
yennanliu wants to merge 8 commits into
mainfrom
ShoppingCart-dev-009-high-concurrency-pt-2
Open

ShoppingCart-dev-009-high-concurrency - Part 2#228
yennanliu wants to merge 8 commits into
mainfrom
ShoppingCart-dev-009-high-concurrency-pt-2

Conversation

@yennanliu
Copy link
Copy Markdown
Owner

Approach 4 — Redisson distributed lock:

  • RedissonConfig: single-server Redisson client bean
  • CartService.addToCart: per-user lock (cart:user:)
  • OrderService.placeOrder: per-user lock (order:user:) 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.

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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support read-replica routing based on the @Transactional(readOnly = true) attribute, you should add the import for LazyConnectionDataSourceProxy.

Suggested change
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

Comment on lines +42 to +56
@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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
@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);
}

Comment on lines 119 to 149
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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();
        }
    }

Comment on lines +23 to +24
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;

yennanliu and others added 3 commits May 9, 2026 09:12
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>
@yennanliu
Copy link
Copy Markdown
Owner Author

================ 2 ================

@yennanliu
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines 119 to 149
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines +51 to +64
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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The routing keys "primary" and "replica" are hardcoded as string literals in multiple places (lines 51, 52, 54, 63, 64). It is recommended to define these as constants to ensure consistency and avoid potential bugs from typos during future maintenance.

config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setConnectionMinimumIdleSize(2)
.setConnectionPoolSize(10);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
.setConnectionPoolSize(10);
.setConnectionPoolSize(64);

yennanliu and others added 4 commits May 9, 2026 10:19
…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>
@yennanliu yennanliu changed the title ShoppingCart-dev-009-high-concurrency-pt-2 ShoppingCart-dev-009-high-concurrency - Part 2 May 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant