1. Service Decomposition and Remote Calls

1.1 Why decompose into services?

As business grows, a monolith faces slow deployment, poor scalability, a frozen tech stack, and more. Microservices split it into small, independently deployable and scalable services, each responsible for a single business domain.

Decomposition principles:

  • Single responsibility: each service does one thing
  • High cohesion, low coupling: tight within a service, loose between services
  • Data independence: each service owns an independent database

1.2 The user-login flow (from a microservice view)

Client → Gateway (auth) → business microservice A → microservice B (OpenFeign)
                ↓
           Nacos (service discovery)

1.3 RestTemplate — the raw inter-service call

RestTemplate is the HTTP client Spring provides for inter-service calls, but its code is verbose and it doesn’t support load balancing — a transitional solution before OpenFeign.

// Register as a Bean and enable load balancing
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
    return new RestTemplate();
}

// Call style (service name replaces IP:Port)
String url = "http://user-service/api/users/" + userId;
User user = restTemplate.getForObject(url, User.class);

⚠️ RestTemplate has gradually been replaced by OpenFeign; prefer OpenFeign in production.


2. Service Governance — the Nacos Registry

2.1 The problem it solves

Microservice instances’ IPs/ports change dynamically, so callers can’t hardcode addresses. A registry provides:

  • Service registration: a service reports its own address to the registry at startup
  • Service discovery: callers query the registry for a target service’s address and implement load balancing
  • Health checks: the registry automatically removes unhealthy instances

2.2 Deploying Nacos (Docker)

docker run -d \
  --name nacos \
  -e MODE=standalone \
  -p 8848:8848 \
  nacos/nacos-server:v2.1.0

Access the console at http://localhost:8848/nacos; the default username and password are both nacos.

2.3 Nacos namespaces

Nacos achieves environment isolation via Namespaces; services in different namespaces are invisible to each other.

Namespace Purpose
public (default) Shared across all environments
dev Development environment
test Test environment
prod Production environment

2.4 Service registration

Add the dependencies:

<!-- Spring Cloud Alibaba dependency management -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2021.0.5.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<!-- Nacos service-discovery dependency -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

Configure the Nacos address (application.yml):

spring:
  application:
    name: user-service        # service name, the identifier registered to Nacos
  cloud:
    nacos:
      server-addr: localhost:8848
      discovery:
        namespace: dev        # namespace ID (not the name)
        group: DEFAULT_GROUP

2.5 Service discovery and load balancing

Spring Cloud LoadBalancer (Spring official) or Ribbon (Netflix, no longer maintained) intercepts @LoadBalanced-annotated RestTemplate / Feign requests, pulls the instance list from the registry, and picks one.

The default strategy is round-robin, customizable to random, weighted, etc.


3. OpenFeign — the Declarative HTTP Client

3.1 The problem it solves

RestTemplate requires manually assembling URLs and handling parameters — repetitive and hard to maintain. OpenFeign lets developers call a remote service like a local method; the interface is the contract.

3.2 Basic usage

Add the dependency:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

Enable Feign on the main class:

@SpringBootApplication
@EnableFeignClients
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

Define a FeignClient interface:

@FeignClient(value = "user-service")   // corresponds to spring.application.name
public interface UserClient {

    @GetMapping("/api/users/{id}")
    User getUserById(@PathVariable("id") Long id);

    @PostMapping("/api/users")
    User createUser(@RequestBody UserDTO dto);
}

Inject and use it on the caller side:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final UserClient userClient;

    public OrderVO getOrder(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        User user = userClient.getUserById(order.getUserId()); // like calling a local method
        return buildVO(order, user);
    }
}

3.3 Adding the OkHttp connection pool

OpenFeign uses URLConnection by default, with no connection pool and poor performance under high concurrency. Replacing it with OkHttp is recommended.

<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-okhttp</artifactId>
</dependency>
feign:
  okhttp:
    enabled: true
  httpclient:
    ok-http:
      connection-pool-timeout: PT0.0005S
      max-connections: 200
      max-connections-per-route: 50

3.4 Best practice — extract an API module

Problem: the provider and consumer each maintain their own copy of the same DTO / FeignClient interface, causing duplicate code.

Solution: create a shared xxx-api module and put the FeignClient interfaces, DTOs, and exception classes there.

Project structure:
├── user-service          # user service (provider)
├── order-service         # order service (consumer)
└── hm-api                # shared API module
    └── client
        └── UserClient.java
    └── dto
        └── UserDTO.java

The consumer adds the API-module dependency:

<dependency>
    <groupId>com.hmall</groupId>
    <artifactId>hm-api</artifactId>
    <version>${project.version}</version>
</dependency>

Resolving the scan-package mismatch:

When the FeignClient isn’t within the @SpringBootApplication’s scan package, you must specify it explicitly:

// Way 1: specify the scan package path
@EnableFeignClients(basePackages = "com.hmall.api.client")

// Way 2: specify the FeignClient classes (more precise)
@EnableFeignClients(clients = {UserClient.class, ItemClient.class})

3.5 Logging configuration

Feign has 4 log levels: NONE (default), BASIC, HEADERS, FULL.

// Global config Bean
@Bean
public Logger.Level feignLogLevel() {
    return Logger.Level.FULL;
}
# Enable logging for a specific FeignClient (with logging.level)
logging:
  level:
    com.hmall.api.client.UserClient: DEBUG

4. Spring Cloud Gateway

4.1 The problem it solves

Calling each microservice directly from the client faces:

  • Each service authenticates on its own — duplicate logic
  • Internal service addresses are exposed — a security risk
  • Cross-cutting concerns like CORS, rate limiting, and logging are scattered

The gateway handles these uniformly and is the single entry point of the microservice system.

4.2 Add the dependencies

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- The gateway also needs to register to Nacos to route via service discovery -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

⚠️ The gateway is based on WebFlux (reactive) — you must not add spring-boot-starter-web; they conflict!

4.3 Route configuration

spring:
  cloud:
    gateway:
      routes:
        - id: user-route                          # route ID, unique
          uri: lb://user-service                  # lb:// means load-balance to the service in Nacos
          predicates:
            - Path=/api/users/**                  # path predicate
          filters:
            - StripPrefix=1                       # strip the path prefix
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
            - Method=GET,POST                     # method predicate
            - Header=X-Request-Id, \d+            # header predicate (regex)
          filters:
            - AddRequestHeader=X-Source, gateway  # add a request header

Common route predicate factories:

Predicate factory Description
Path Path matching
Method HTTP method matching
Header Header matching (supports regex)
Query Request-parameter matching
After/Before/Between Time predicates
RemoteAddr IP-address predicate

4.4 The gateway request-handling flow

Client Request
    ↓
HttpWebHandlerAdapter
    ↓
DispatcherHandler
    ↓
RoutePredicateHandlerMapping  ← match the route
    ↓
FilteringWebHandler
    ↓
[Global Filters] → [GatewayFilter Chain] → NettyRoutingFilter (the actual forwarding)
    ↓
Proxied Service

4.5 Gateway login verification — a custom GlobalFilter

@Component
@RequiredArgsConstructor
@Order(-1)   // priority; the smaller, the earlier it runs
public class AuthGlobalFilter implements GlobalFilter {

    private final JwtTool jwtTool;

    // Whitelist paths (no auth needed)
    private static final List<String> WHITE_LIST = List.of(
        "/api/users/login",
        "/api/users/register"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getPath().value();

        // 1. Let whitelisted paths through
        if (isWhitePath(path)) {
            return chain.filter(exchange);
        }

        // 2. Get the token
        String token = getTokenFromRequest(request);
        if (token == null) {
            return unauthorized(exchange);
        }

        // 3. Parse the token
        Long userId;
        try {
            userId = jwtTool.parseToken(token);
        } catch (Exception e) {
            return unauthorized(exchange);
        }

        // 4. Pass the user ID to downstream microservices
        ServerWebExchange mutatedExchange = exchange.mutate()
            .request(builder -> builder.header("X-User-Id", userId.toString()))
            .build();

        return chain.filter(mutatedExchange);
    }

    private boolean isWhitePath(String path) {
        return WHITE_LIST.stream().anyMatch(path::startsWith);
    }

    private String getTokenFromRequest(ServerHttpRequest request) {
        List<String> headers = request.getHeaders().get("Authorization");
        if (headers == null || headers.isEmpty()) return null;
        String auth = headers.get(0);
        return auth.startsWith("Bearer ") ? auth.substring(7) : null;
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }
}

4.6 Getting the current user in a microservice — an MVC interceptor

Write an interceptor in the common module that reads the user ID passed by the gateway from the request header and stores it in UserContext (a ThreadLocal):

public class UserInfoInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) {
        String userIdStr = request.getHeader("X-User-Id");
        if (StringUtils.hasText(userIdStr)) {
            UserContext.setUser(Long.parseLong(userIdStr));
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        UserContext.removeUser(); // prevent memory leaks!
    }
}
// ThreadLocal utility class
public class UserContext {
    private static final ThreadLocal<Long> TL = new ThreadLocal<>();
    public static void setUser(Long userId) { TL.set(userId); }
    public static Long getUser() { return TL.get(); }
    public static void removeUser() { TL.remove(); }
}

Avoiding the gateway pulling in MVC auto-configuration:

Auto-configure MvcConfig (the config class that registers the interceptor) via spring.factories, and exclude it in the gateway module:

// common module: resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.hmall.common.config.MvcConfig
# Exclude it in the gateway module's application.yml
spring:
  autoconfigure:
    exclude:
      - com.hmall.common.config.MvcConfig

4.7 Passing the user via OpenFeign — a RequestInterceptor

When microservice A calls microservice B via Feign, it must carry the current user ID:

@Bean
public RequestInterceptor userInfoRequestInterceptor() {
    return template -> {
        Long userId = UserContext.getUser();
        if (userId != null) {
            template.header("X-User-Id", userId.toString());
        }
    };
}

Every request OpenFeign sends passes through the RequestInterceptor first, automatically carrying the user info.


5. Config Management — Nacos Config

5.1 Why it matters

Pain point Nacos Config solution
Same config written repeatedly across services Config sharing: multiple services read the same config
Changing config requires restarting the service Hot reload: takes effect dynamically at runtime
Config for different environments is a mess Isolate via Namespace + Group

5.2 Add the dependencies

<!-- Nacos config center -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Bootstrap context, to load Nacos config before the app starts -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

5.3 bootstrap.yml vs application.yml

Comparison bootstrap.yml application.yml
Load timing Before the app starts (bootstrap phase) At app startup
Purpose Connect to the config center, encryption keys, etc. Business config
Priority Low (overridden by application.yml) High
# bootstrap.yml — pull shared config from Nacos
spring:
  application:
    name: user-service
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr: localhost:8848
      config:
        file-extension: yaml
        shared-configs:
          - data-id: shared-jdbc.yaml      # shared database config
            group: DEFAULT_GROUP
            refresh: true
          - data-id: shared-redis.yaml     # shared Redis config
            group: DEFAULT_GROUP
            refresh: true
        # the service's own config: user-service-dev.yaml (auto-recognized)

5.4 Config hot reload

Add an annotation to the Bean holding the config properties that need hot reloading:

// Way 1: @RefreshScope (refresh the whole Bean)
@Component
@RefreshScope
public class CartProperties {
    @Value("${cart.max-amount:100}")
    private Integer maxAmount;
}

// Way 2: @ConfigurationProperties (recommended, safer)
@Component
@ConfigurationProperties(prefix = "cart")
@Data
public class CartProperties {
    private Integer maxAmount = 100;
    // Auto-refreshes after a change in Nacos, no @RefreshScope needed
}

5.5 Dynamic routing

Gateway route rules can also live in Nacos, so routes update dynamically without restarting the gateway:

@Component
@RequiredArgsConstructor
public class DynamicRouteLoader implements ApplicationRunner {

    private final RouteDefinitionWriter writer;
    private final NacosConfigManager nacosConfigManager;

    private static final String DATA_ID = "gateway-routes.json";
    private static final String GROUP = "DEFAULT_GROUP";

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // Initial pull
        loadRoutes();
        // Listen for changes
        nacosConfigManager.getConfigService().addListener(DATA_ID, GROUP,
            new Listener() {
                @Override
                public void receiveConfigInfo(String configInfo) {
                    loadRoutes();
                }
                @Override
                public Executor getExecutor() { return null; }
            });
    }

    private void loadRoutes() {
        // Parse JSON and register route definitions
        // ...
    }
}

6. Microservice Protection — Sentinel

6.1 The avalanche problem (cascading failure)

Service D goes down → threads pile up in C waiting on D → C also exhausts threads → B too... → the whole system crashes

Four protection tactics:

Tactic Scenario Tool
Rate limiting Prevent traffic spikes Sentinel flow rules
Thread isolation Prevent a faulty service from dragging down callers Sentinel isolation / Hystrix bulkhead
Circuit breaking Fail fast when the error ratio is too high Sentinel circuit breaker
Fallback Backup logic during degradation FallbackFactory

6.2 Add Sentinel

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8090   # Sentinel console address
      eager: true                  # initialize early, so the first request's resource is visible

Start the Sentinel console:

java -Dserver.port=8090 \
     -Dcsp.sentinel.dashboard.server=localhost:8090 \
     -Dproject.name=sentinel-dashboard \
     -jar sentinel-dashboard.jar

6.3 Rate limiting (flow control)

Flow rules are configured in the Sentinel console; the core parameters:

Parameter Description
QPS (Queries Per Second) The per-second request threshold
Concurrent threads The threshold for simultaneously handled threads
Flow-control mode Direct / associated / chain
Flow-control effect Fail fast / queue and wait (constant rate) / warm up

Queue-and-wait (leaky bucket) suits peak-shaving scenarios.

6.4 Thread isolation

Sentinel uses semaphore isolation (not thread-pool isolation), with low resource overhead:

Configure a “concurrent threads” limit on a cluster-point resource in the console; requests exceeding the threshold go straight to the Fallback and won’t pile up indefinitely.

Duplicate cluster-point resource name problem: when OpenFeign calls on different paths map to the same method, Sentinel’s cluster-point chain gets duplicate names; ensure requestUri is unique or customize the resource name via annotation.

6.5 Fallback (degradation backup)

Bring OpenFeign calls under Sentinel management and write the fallback logic:

feign:
  sentinel:
    enabled: true
// 1. Implement FallbackFactory
@Component
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {

    @Override
    public UserClient create(Throwable cause) {
        log.error("UserClient call failed", cause);
        return new UserClient() {
            @Override
            public User getUserById(Long id) {
                // Return a default or empty object to avoid NPE
                return User.builder().id(id).name("Unknown user").build();
            }
        };
    }
}
// 2. Reference the factory in the FeignClient
@FeignClient(value = "user-service", fallbackFactory = UserClientFallbackFactory.class)
public interface UserClient {
    @GetMapping("/api/users/{id}")
    User getUserById(@PathVariable("id") Long id);
}
// 3. Register the factory as a Bean (if in the hm-api module, ensure it's scanned)
@Bean
public UserClientFallbackFactory userClientFallbackFactory() {
    return new UserClientFallbackFactory();
}

6.6 Circuit breaking

Sentinel’s circuit breaker is based on a three-state machine:

Normal ──────────────────────────────────────────► Closed (normal requests)
                      ↓ error ratio / slow-call ratio exceeds threshold
              Open (fail fast, no longer call downstream)
                      ↓ the break duration ends
             Half-Open (let one probe request through)
                ↓ success                ↓ failure
             Closed                 Open

Configure the break rules in the console:

  • Slow-call ratio: the proportion of requests with RT over the threshold triggers breaking
  • Error ratio: the proportion of error requests over total triggers breaking
  • Error count: the number of errors in the stats window over the threshold triggers breaking

6.7 Tomcat thread configuration

Combined with Sentinel’s thread isolation, control the Tomcat thread pool size sensibly:

server:
  port: 8082
  tomcat:
    threads:
      max: 25           # max worker threads
      min-spare: 5      # min idle threads
    accept-count: 25    # wait-queue size
    max-connections: 100

7. Distributed Transactions — Seata

7.1 Background

Order service (deduct stock) → Inventory service (deduct stock) → Points service (add points)

Three operations span three databases; local @Transactional can only guarantee atomicity within a single database. A microservice architecture needs distributed transactions to guarantee cross-service consistency.

Core distributed-transaction concepts:

  • Global Transaction: one complete business operation spanning multiple services/databases
  • Branch Transaction: the local transaction within each service in the global transaction
  • TC (Transaction Coordinator): maintains global and branch transaction states
  • TM (Transaction Manager): the initiator, defines the start and end of the global transaction
  • RM (Resource Manager): each microservice, manages branch transactions

7.2 Deploying Seata (Docker)

Step 1: prepare the database tables for TC storage

CREATE DATABASE IF NOT EXISTS `seata`;
USE `seata`;

CREATE TABLE IF NOT EXISTS `global_table` (
    `xid` VARCHAR(128) NOT NULL,
    `transaction_id` BIGINT,
    `status` TINYINT NOT NULL,
    `application_id` VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name` VARCHAR(128),
    `timeout` INT,
    `begin_time` BIGINT,
    `application_data` VARCHAR(2000),
    `gmt_create` DATETIME,
    `gmt_modified` DATETIME,
    PRIMARY KEY (`xid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- branch_table, lock_table, distributed_lock are similar (see the official docs)

Step 2: start the Seata Server

docker run --name seata \
  -p 8099:8099 \
  -p 7099:7099 \
  -e SEATA_IP=192.168.184.129 \
  -v ./seata:/seata-server/resources \
  --privileged=true \
  --network testNet \
  -d seataio/seata-server:1.5.2
  • 7099: the web console
  • 8099: the microservice registration port

7.3 Integrating Seata into a microservice

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
seata:
  registry:
    type: nacos
    nacos:
      server-addr: localhost:8848
      namespace: ""
      group: DEFAULT_GROUP
      application: seata-server   # the Seata Server's service name in Nacos
  tx-service-group: hmall         # transaction group name
  service:
    vgroup-mapping:
      hmall: default              # transaction group → cluster mapping
  data-source-proxy-mode: AT      # AT mode (default)

Annotate the transaction initiator:

@Service
@RequiredArgsConstructor
public class OrderService {

    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    public void createOrder(OrderDTO dto) {
        // 1. Deduct stock (remote call → Seata RM registers a branch transaction)
        itemClient.deductStock(dto.getItemId(), dto.getNum());
        // 2. Deduct balance (remote call → Seata RM registers a branch transaction)
        userClient.deductBalance(dto.getUserId(), dto.getAmount());
        // 3. Create the order (local operation → Seata RM registers a branch transaction)
        save(buildOrder(dto));
    }
}

7.4 XA mode vs AT mode

XA mode

Phase 1: TM tells each RM to run SQL but not commit → locks resources
Phase 2: all branches succeed → TM tells them to commit; any failure → TM tells all to roll back
seata:
  data-source-proxy-mode: XA
// Enable XA support
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource(DruidDataSourceWrapper druidDataSourceWrapper) {
    return new DataSourceProxyXA(druidDataSourceWrapper);
}

Characteristics:

  • Strong consistency (data is invisible until phase 2 ends)
  • ❌ Long resource-lock time, low throughput
Phase 1: record an undo log (snapshot) → commit the local transaction directly (release the lock)
Phase 2 success: delete the undo log
Phase 2 failure: compensate and roll back in reverse via the undo log

Each database participating in AT mode needs an undo_log table:

CREATE TABLE IF NOT EXISTS `undo_log` (
    `branch_id`     BIGINT NOT NULL COMMENT 'branch transaction id',
    `xid`           VARCHAR(128) NOT NULL COMMENT 'global transaction id',
    `context`       VARCHAR(128) NOT NULL COMMENT 'serialization',
    `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
    `log_status`    INT(11) NOT NULL COMMENT '0:normal,1:defense',
    `log_created`   DATETIME(6) NOT NULL,
    `log_modified`  DATETIME(6) NOT NULL,
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4;

Characteristics:

  • ✅ High throughput (locks released in phase 1)
  • ⚠️ Eventual consistency (a brief window of inconsistency exists)

Comparison of the two modes

Comparison XA AT
Consistency Strong Eventual
Lock granularity Database lock (long) Row lock (short)
Performance Low High
Suited for Core financial transactions E-commerce, general business

7.5 @GlobalTransactional vs @Transactional

Annotation Scope Coordinator
@Transactional A single service’s local transaction The local database
@GlobalTransactional A cross-service global transaction Seata TC

The two can coexist: @GlobalTransactional controls the global on the TM side, @Transactional controls the local on the RM side.


8. Message Queue — RabbitMQ

8.1 Synchronous vs asynchronous calls

Comparison Synchronous (OpenFeign) Asynchronous (MQ)
Coupling High (strongly depends on the other’s availability) Low (decoupled via the broker)
Performance Serial, low throughput Parallel, high throughput
Reliability Caller waits for a response Messages persisted, retryable
Suited for Core business (needs immediate results) Edge business (logs, notifications, points)

8.2 RabbitMQ core concepts

Publisher → Exchange → [Binding] → Queue → Consumer
Concept Description
Publisher The message sender
Exchange Handles routing
Queue Stores messages
Consumer The message consumer
Virtual Host A data-isolation unit
Binding Key The rule binding a queue to an exchange

8.3 Getting started with Spring AMQP

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
spring:
  rabbitmq:
    host: localhost
    port: 5672
    virtual-host: /hmall
    username: hmall
    password: 123456

Send a message:

@Service
@RequiredArgsConstructor
public class PayService {
    private final RabbitTemplate rabbitTemplate;

    public void notifyPaySuccess(Long orderId) {
        rabbitTemplate.convertAndSend(
            "pay.direct",     // exchange
            "pay.success",    // routing key
            orderId           // message body (auto-serialized when using the JSON converter)
        );
    }
}

Consume a message:

@Component
@Slf4j
public class PayMessageListener {

    @RabbitListener(queues = "pay.queue")
    public void onPaySuccess(Long orderId) {
        log.info("received pay-success message, order ID: {}", orderId);
        // update the order status
    }
}

8.4 The three exchange types

FanoutExchange (broadcast)

// Declaration (consumer side)
@Bean
public FanoutExchange fanoutExchange() {
    return new FanoutExchange("hmall.fanout");
}

@Bean
public Queue fanoutQueue1() {
    return QueueBuilder.durable("fanout.queue1").build();
}

@Bean
public Binding binding1(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
    return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}

DirectExchange (directed routing)

// Annotation-based declaration (more concise, recommended)
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "direct.queue1", durable = "true"),
    exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
    key = {"red", "blue"}
))
public void listenDirectQueue1(String message) {
    log.info("direct.queue1 received: {}", message);
}

TopicExchange (topic routing, wildcards)

Wildcard Meaning
* Matches one word
# Matches zero or more words
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "topic.queue1", durable = "true"),
    exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
    key = "china.#"   // matches all routing keys starting with china.
))
public void listenTopicQueue1(String message) {
    log.info("topic.queue1 received: {}", message);
}

8.5 Message converter (JSON)

The default is JDK serialization: poor security, large size, poor readability. Replacing it with Jackson JSON is recommended:

<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
</dependency>
// Both publisher and consumer need this config
@Bean
public MessageConverter jacksonMessageConverter() {
    Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
    converter.setCreateMessageIds(true); // auto-generate message IDs for idempotency checks
    return converter;
}

8.6 Consumer push limit (prefetch count)

By default RabbitMQ distributes all messages evenly across consumers, ignoring their processing capacity.

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1   # deliver at most 1 message at a time; the next only after this one is processed

Effect: faster consumers automatically handle more messages (the capable do more), avoiding backlog.

8.7 Message reliability guarantees

Producer confirmation mechanism

spring:
  rabbitmq:
    publisher-confirm-type: correlated  # async callback confirmation (recommended)
    publisher-returns: true             # enable routing-failure callback
    template:
      mandatory: true
@PostConstruct  // runs after the Bean is initialized
public void init() {
    // Callback on routing failure (message reached the Exchange but wasn't routed to a Queue)
    rabbitTemplate.setReturnsCallback(returnedMessage -> {
        log.error("message routing failed: exchange={}, routingKey={}, message={}",
            returnedMessage.getExchange(),
            returnedMessage.getRoutingKey(),
            returnedMessage.getMessage());
        // resend or log it
    });
}

public void sendWithConfirm(String exchange, String key, Object msg) {
    CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
    cd.getFuture().addCallback(
        confirm -> {
            if (confirm.isAck()) {
                log.info("message reached the Exchange, ID: {}", cd.getId());
            } else {
                log.error("message didn't reach the Exchange, reason: {}, ID: {}", confirm.getReason(), cd.getId());
                // resend logic
            }
        },
        throwable -> log.error("message send exception", throwable)
    );
    rabbitTemplate.convertAndSend(exchange, key, msg, cd);
}

When can you confirm the message reached the queue?

  1. ConfirmCallback.onSuccess receives an ACK → the message reached the Exchange
  2. ReturnsCallback was not triggered → the message was routed to the Queue successfully

MQ data persistence

// Exchange persistence (durable=true by default)
@Bean
public DirectExchange payExchange() {
    return ExchangeBuilder.directExchange("pay.direct").durable(true).build();
}

// Queue persistence
@Bean
public Queue payQueue() {
    return QueueBuilder.durable("pay.queue").build();
}
// Message persistence (Spring AMQP defaults to persistent)
rabbitTemplate.convertAndSend(exchange, key, msg, message -> {
    message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
    return message;
});

Lazy Queue

Write messages straight to disk instead of memory — good for backlog scenarios:

// Bean way
@Bean
public Queue lazyQueue() {
    return QueueBuilder.durable("lazy.queue")
        .lazy()   // set as a lazy queue
        .build();
}

// Annotation way
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(
        name = "lazy.queue",
        durable = "true",
        arguments = @Argument(name = "x-queue-mode", value = "lazy")
    ),
    ...
))

8.8 Consumer reliability

Consumer acknowledgment mechanism

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto  # recommended: around-enhancement, auto ack/nack
        # none: ack immediately, unsafe
        # manual: manual ack, flexible but intrusive to code

Under auto mode:

  • The method runs normally → auto ack
  • An exception is thrown → auto nack, the message is requeued
  • An AmqpRejectAndDontRequeueException is thrown → discard it directly

Failure retry strategy

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true
          initial-interval: 1000ms  # initial retry interval
          multiplier: 1.0           # interval multiplier
          max-attempts: 3           # max retries
          stateless: true           # stateless retry

Strategy after retries are exhausted:

@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) {
    // Recommended: after retries are exhausted, deliver the failed message to a designated "error exchange"
    return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
Strategy Description
RejectAndDontRequeueRecover Discard directly (default)
ImmediateRequeueMessageRecover Requeue (may cause an infinite loop)
RepublishMessageRecover Send to an error queue (recommended)

8.9 Business idempotency

Since a message may be consumed repeatedly, the business logic must be idempotent:

@RabbitListener(queues = "pay.queue")
public void onPaySuccess(Message message) {
    String msgId = message.getMessageProperties().getMessageId();

    // Way 1: dedupe with message ID + Redis
    Boolean isFirst = redisTemplate.opsForValue()
        .setIfAbsent("pay:msg:" + msgId, "1", 30, TimeUnit.MINUTES);
    if (Boolean.FALSE.equals(isFirst)) {
        log.warn("duplicate message, ignoring: {}", msgId);
        return;
    }

    Long orderId = (Long) rabbitTemplate.getMessageConverter().fromMessage(message);

    // Way 2: check the business state
    Order order = orderService.getById(orderId);
    if (order.getStatus() != OrderStatus.UNPAID) {
        log.warn("order status already updated, ignoring duplicate message: {}", orderId);
        return;
    }

    orderService.updateStatus(orderId, OrderStatus.PAID);
}

8.10 Delayed messages

Way 1: dead-letter exchange (TTL + DLX)

@Bean
public Queue ttlQueue() {
    return QueueBuilder.durable("ttl.queue")
        .ttl(30000)                          // message lives 30 seconds
        .deadLetterExchange("dlx.direct")    // forwards to the DLX after expiry
        .deadLetterRoutingKey("dlx.order")
        .build();
}

⚠️ Drawback: an unexpired message at the queue head blocks later messages, even if those later ones have already expired.

After installing the rabbitmq_delayed_message_exchange plugin:

@Bean
public DirectExchange delayedExchange() {
    return ExchangeBuilder.directExchange("delayed.exchange")
        .delayed()   // declare as a delayed exchange
        .durable(true)
        .build();
}

// Specify the delay when sending
rabbitTemplate.convertAndSend("delayed.exchange", "delay.key", orderId, message -> {
    message.getMessageProperties().setDelayLong(30 * 60 * 1000L); // delay 30 minutes
    return message;
});

8.11 High-frequency interview questions

Q: How do you keep order status consistent between the payment service and the trade service?

  1. After payment succeeds, the payment service sends a message via MQ to notify the trade service to sync the order status
  2. Reliability guarantees: producer confirmation (ConfirmCallback + ReturnCallback) + consumer acknowledgment (auto mode) + consumer failure retry + MQ persistence (exchange/queue/message)
  3. Idempotency guarantee: the trade service checks the current status before updating the order, preventing exceptions from duplicate consumption

Q: If the trade service fails to process a message, is there a fallback plan?

  1. MQ side: failure retry + RepublishMessageRecoverer delivers the failed message to an error queue, handled manually or by a scheduled task
  2. Business side: a scheduled task scans orders unpaid/unsynced for a long time and proactively queries the payment result from the payment service (compensation mechanism)
  3. Monitoring side: configure alerts on the error queue and consumption lag

Summary

Problem Solution
Verbose HTTP calls between microservices OpenFeign
Service instances change dynamically, can’t hardcode addresses Nacos registry
Each microservice authenticates on its own, duplicate logic Spring Cloud Gateway
Scattered config, changes require restarts Nacos Config
Service avalanche / traffic spikes Sentinel
Cross-service data consistency Seata
Tight coupling between services, poor synchronous-call performance RabbitMQ