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
RequestInterceptorfirst, 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
requestUriis 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 console8099: 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
AT mode (recommended)
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?
ConfirmCallback.onSuccessreceives anACK→ the message reached the ExchangeReturnsCallbackwas 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
AmqpRejectAndDontRequeueExceptionis 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.
Way 2: the RabbitMQ delayed-message plugin (recommended)
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?
- After payment succeeds, the payment service sends a message via MQ to notify the trade service to sync the order status
- Reliability guarantees: producer confirmation (ConfirmCallback + ReturnCallback) + consumer acknowledgment (auto mode) + consumer failure retry + MQ persistence (exchange/queue/message)
- 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?
- MQ side: failure retry +
RepublishMessageRecovererdelivers the failed message to an error queue, handled manually or by a scheduled task- 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)
- 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 |