日志校验与幂等最佳实践
概念与背景
很多项目表面上"功能能跑",但一到线上就暴露出三个大坑:
- 日志打不出关键上下文
- 参数校验不统一
- 重复请求和重复消息没有幂等保护
这三个问题分散在不同层,但本质都属于工程稳定性基础设施。
一、日志最佳实践
1.1 日志的目标
日志不是越多越好,而是要回答几个关键问题:
- 谁在什么时间做了什么事
- 请求链路标识是什么
- 失败时关键上下文是什么
- 是否方便定位到业务单号、用户 ID、请求参数范围
1.2 日志级别规范
| 级别 | 使用场景 | 示例 |
|---|---|---|
| ERROR | 系统异常、业务异常 | 数据库连接失败、第三方服务超时 |
| WARN | 潜在问题、需要关注 | 慢查询、重试成功、降级触发 |
| INFO | 关键业务节点 | 订单创建、支付成功、状态变更 |
| DEBUG | 调试信息 | 方法入参出参、中间状态 |
1.3 结构化日志实现
java
@Data
@Builder
public class LogContext {
private String traceId;
private String userId;
private String bizNo;
private String action;
private String method;
private Map<String, Object> params;
private String result;
private long costTime;
private String error;
public String toJson() {
return JsonUtils.toJson(this);
}
}
@Slf4j
public class LogHelper {
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
public static void start(String method, Map<String, Object> params) {
START_TIME.set(System.currentTimeMillis());
LogContext context = LogContext.builder()
.traceId(TraceContext.getTraceId())
.userId(UserContext.getUserId())
.method(method)
.params(params)
.build();
log.info("[START] {}", context.toJson());
}
public static void end(String bizNo, String result) {
long cost = System.currentTimeMillis() - START_TIME.get();
LogContext context = LogContext.builder()
.traceId(TraceContext.getTraceId())
.userId(UserContext.getUserId())
.bizNo(bizNo)
.result(result)
.costTime(cost)
.build();
log.info("[END] {}", context.toJson());
START_TIME.remove();
}
public static void error(String bizNo, String error, Throwable e) {
long cost = System.currentTimeMillis() - START_TIME.get();
LogContext context = LogContext.builder()
.traceId(TraceContext.getTraceId())
.userId(UserContext.getUserId())
.bizNo(bizNo)
.error(error)
.costTime(cost)
.build();
log.error("[ERROR] {} exception={}", context.toJson(), e.getMessage(), e);
START_TIME.remove();
}
}1.4 TraceId 链路追踪
java
public class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
private static final String HEADER_TRACE_ID = "X-Trace-Id";
public static void setTraceId(String traceId) {
if (StringUtils.isBlank(traceId)) {
traceId = generateTraceId();
}
TRACE_ID.set(traceId);
MDC.put("traceId", traceId);
}
public static String getTraceId() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
MDC.remove("traceId");
}
private static String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "");
}
}
@Slf4j
@Component
public class TraceFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String traceId = httpRequest.getHeader(TraceContext.HEADER_TRACE_ID);
try {
TraceContext.setTraceId(traceId);
chain.doFilter(request, response);
} finally {
TraceContext.clear();
}
}
}1.5 敏感信息脱敏
java
public class SensitiveDataMasker {
private static final Pattern PHONE_PATTERN = Pattern.compile("(\\d{3})\\d{4}(\\d{4})");
private static final Pattern ID_CARD_PATTERN = Pattern.compile("(\\d{4})\\d{10}(\\d{4})");
private static final Pattern BANK_CARD_PATTERN = Pattern.compile("(\\d{4})\\d+(\\d{4})");
public static String maskPhone(String phone) {
if (StringUtils.isBlank(phone)) {
return phone;
}
return PHONE_PATTERN.matcher(phone).replaceAll("$1****$2");
}
public static String maskIdCard(String idCard) {
if (StringUtils.isBlank(idCard)) {
return idCard;
}
return ID_CARD_PATTERN.matcher(idCard).replaceAll("$1**********$2");
}
public static String maskBankCard(String bankCard) {
if (StringUtils.isBlank(bankCard)) {
return bankCard;
}
return BANK_CARD_PATTERN.matcher(bankCard).replaceAll("$1****$2");
}
public static String mask(String fieldName, String value) {
if (StringUtils.isBlank(value)) {
return value;
}
String lowerField = fieldName.toLowerCase();
if (lowerField.contains("phone") || lowerField.contains("mobile")) {
return maskPhone(value);
}
if (lowerField.contains("idcard") || lowerField.contains("id_card")) {
return maskIdCard(value);
}
if (lowerField.contains("bank") || lowerField.contains("card")) {
return maskBankCard(value);
}
if (lowerField.contains("password") || lowerField.contains("pwd")) {
return "******";
}
return value;
}
}
@Slf4j
public class SensitiveLogAspect {
@Around("@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping)")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
Map<String, Object> params = new LinkedHashMap<>();
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
if (arg instanceof HttpServletRequest) {
continue;
}
String json = JsonUtils.toJson(arg);
Map<String, Object> map = JsonUtils.fromJson(json, Map.class);
if (map != null) {
map.forEach((key, value) -> {
if (value instanceof String) {
params.put(key, SensitiveDataMasker.mask(key, (String) value));
} else {
params.put(key, value);
}
});
}
}
log.info("请求参数: {}", JsonUtils.toJson(params));
return joinPoint.proceed();
}
}二、参数校验最佳实践
2.1 校验的目标
参数校验的核心目标是尽早挡住非法输入,避免脏数据流入业务逻辑。
常见做法:
- DTO 层做基础格式校验
- 业务层做规则校验
- 全局异常处理统一返回校验错误
2.2 常用校验注解
| 注解 | 说明 | 示例 |
|---|---|---|
| @NotNull | 不能为 null | @NotNull Long userId |
| @NotBlank | 字符串不能为空 | @NotBlank String name |
| @NotEmpty | 集合不能为空 | @NotEmpty List<Long> ids |
| @Size | 集合或字符串长度 | @Size(min=1, max=10) List<String> tags |
| @Min @Max | 数值范围 | @Min(1) @Max(100) Integer age |
| @Pattern | 正则匹配 | @Pattern(regexp = "^1[3-9]\d{9}$") String phone |
| 邮箱格式 | @Email String email | |
| @Past @Future | 时间约束 | @Past LocalDate birthday |
2.3 分组校验
java
public interface CreateGroup {}
public interface UpdateGroup {}
public record UserRequest(
@NotNull(groups = UpdateGroup.class)
Long id,
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
@Size(min = 2, max = 20, groups = {CreateGroup.class, UpdateGroup.class})
String username,
@NotBlank(groups = CreateGroup.class)
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$",
groups = {CreateGroup.class, UpdateGroup.class})
String password,
@Email(groups = {CreateGroup.class, UpdateGroup.class})
String email,
@Pattern(regexp = "^1[3-9]\\d{9}$", groups = {CreateGroup.class, UpdateGroup.class})
String phone
) {}
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public ApiResponse<Long> create(@RequestBody @Validated(CreateGroup.class) UserRequest request) {
return ApiResponse.success(userService.create(request));
}
@PutMapping("/{id}")
public ApiResponse<Void> update(@PathVariable Long id,
@RequestBody @Validated(UpdateGroup.class) UserRequest request) {
userService.update(request);
return ApiResponse.success(null);
}
}2.4 自定义校验注解
java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValue {
String message() default "枚举值不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass();
String method() default "getValue";
}
public class EnumValidator implements ConstraintValidator<EnumValue, Object> {
private Class<? extends Enum<?>> enumClass;
private String method;
private Set<Object> validValues;
@Override
public void initialize(EnumValue annotation) {
this.enumClass = annotation.enumClass();
this.method = annotation.method();
this.validValues = new HashSet<>();
try {
Method valueMethod = enumClass.getMethod(method);
for (Enum<?> enumConstant : enumClass.getEnumConstants()) {
validValues.add(valueMethod.invoke(enumConstant));
}
} catch (Exception e) {
throw new IllegalArgumentException("枚举校验初始化失败", e);
}
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return validValues.contains(value);
}
}
@Getter
@AllArgsConstructor
public enum OrderStatus {
CREATED(0, "已创建"),
PAID(1, "已支付"),
SHIPPED(2, "已发货"),
COMPLETED(3, "已完成"),
CANCELLED(4, "已取消");
private final int value;
private final String desc;
}
public record OrderRequest(
@EnumValue(enumClass = OrderStatus.class, message = "订单状态不合法")
Integer status
) {}2.5 业务规则校验
java
public interface BusinessValidator<T> {
void validate(T request);
default void validateWithThrow(T request) {
ValidationResult result = validateWithResult(request);
if (!result.isSuccess()) {
throw new BusinessException(result.getErrorCode(), result.getMessage());
}
}
default ValidationResult validateWithResult(T request) {
try {
validate(request);
return ValidationResult.success();
} catch (BusinessException e) {
return ValidationResult.fail(e.getCode(), e.getMessage());
}
}
}
@Data
@Builder
public class ValidationResult {
private boolean success;
private int errorCode;
private String message;
public static ValidationResult success() {
return ValidationResult.builder().success(true).build();
}
public static ValidationResult fail(int errorCode, String message) {
return ValidationResult.builder()
.success(false)
.errorCode(errorCode)
.message(message)
.build();
}
}
@Component
public class OrderValidator implements BusinessValidator<CreateOrderRequest> {
@Autowired
private ProductService productService;
@Autowired
private UserService userService;
@Override
public void validate(CreateOrderRequest request) {
if (!userService.exists(request.userId())) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
Product product = productService.getById(request.productId());
if (product == null) {
throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND);
}
if (product.getStock() < request.quantity()) {
throw new BusinessException(ErrorCode.INSUFFICIENT_STOCK);
}
if (product.getStatus() != ProductStatus.ON_SALE.getValue()) {
throw new BusinessException(ErrorCode.PRODUCT_NOT_AVAILABLE);
}
}
}
@Service
public class OrderService {
@Autowired
private OrderValidator orderValidator;
@Transactional
public Long createOrder(CreateOrderRequest request) {
orderValidator.validateWithThrow(request);
Order order = buildOrder(request);
orderMapper.insert(order);
productService.deductStock(request.productId(), request.quantity());
return order.getId();
}
}三、幂等性最佳实践
3.1 幂等的目标
幂等要解决的是重复执行问题:
- 用户连点提交
- 接口超时重试
- MQ 重复投递
- 第三方重复回调
最稳的幂等通常还是落在业务结果层,而不是只靠前端防抖。
3.2 幂等性设计原则
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 唯一业务单号 | 创建类操作 | 简单可靠 | 需要业务方生成单号 |
| 数据库唯一索引 | 数据插入 | 数据库原生支持 | 只能防重复插入 |
| 状态机约束 | 状态流转 | 业务语义清晰 | 需要设计状态机 |
| Token 机制 | 表单提交 | 防止 CSRF | 需要两次请求 |
| 分布式锁 | 并发控制 | 灵活通用 | 需要额外存储 |
3.3 唯一业务单号幂等
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String IDEMPOTENT_KEY_PREFIX = "idempotent:order:";
@Transactional
public Long createOrder(CreateOrderRequest request) {
String idempotentKey = IDEMPOTENT_KEY_PREFIX + request.orderNo();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "processing", Duration.ofMinutes(30));
if (Boolean.FALSE.equals(acquired)) {
Order existingOrder = orderMapper.selectByOrderNo(request.orderNo());
if (existingOrder != null) {
return existingOrder.getId();
}
throw new BusinessException(ErrorCode.ORDER_PROCESSING);
}
try {
Order order = buildOrder(request);
orderMapper.insert(order);
redisTemplate.opsForValue().set(idempotentKey, order.getId(), Duration.ofHours(24));
return order.getId();
} catch (Exception e) {
redisTemplate.delete(idempotentKey);
throw e;
}
}
public Order getByOrderNo(String orderNo) {
return orderMapper.selectByOrderNo(orderNo);
}
}3.4 数据库唯一索引幂等
sql
CREATE TABLE `user_coupon` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`coupon_id` bigint NOT NULL,
`order_no` varchar(64) NOT NULL COMMENT '关联订单号',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_coupon` (`user_id`, `coupon_id`),
UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;java
@Service
public class CouponService {
@Autowired
private UserCouponMapper userCouponMapper;
@Transactional
public Long receiveCoupon(Long userId, Long couponId, String orderNo) {
UserCoupon userCoupon = UserCoupon.builder()
.userId(userId)
.couponId(couponId)
.orderNo(orderNo)
.status(CouponStatus.UNUSED.getValue())
.build();
try {
userCouponMapper.insert(userCoupon);
return userCoupon.getId();
} catch (DuplicateKeyException e) {
UserCoupon existing = userCouponMapper.selectByUserIdAndCouponId(userId, couponId);
if (existing != null) {
return existing.getId();
}
throw new BusinessException(ErrorCode.COUPON_ALREADY_RECEIVED);
}
}
}3.5 状态机幂等
java
@Getter
@AllArgsConstructor
public enum OrderStatus {
CREATED(0, "已创建"),
PAID(1, "已支付"),
SHIPPED(2, "已发货"),
COMPLETED(3, "已完成"),
CANCELLED(4, "已取消");
private final int value;
private final String desc;
private static final Map<Integer, List<OrderStatus>> TRANSITIONS = Map.of(
CREATED.value, List.of(PAID, CANCELLED),
PAID.value, List.of(SHIPPED, CANCELLED),
SHIPPED.value, List.of(COMPLETED),
COMPLETED.value, List.of(),
CANCELLED.value, List.of()
);
public boolean canTransitionTo(OrderStatus target) {
return TRANSITIONS.getOrDefault(this.value, List.of()).contains(target);
}
}
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional
public void payOrder(Long orderId, String payNo) {
Order order = orderMapper.selectByIdForUpdate(orderId);
if (order == null) {
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
}
OrderStatus currentStatus = OrderStatus.fromValue(order.getStatus());
if (!currentStatus.canTransitionTo(OrderStatus.PAID)) {
if (currentStatus == OrderStatus.PAID) {
return;
}
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR,
"订单状态不允许支付: " + currentStatus.getDesc());
}
order.setStatus(OrderStatus.PAID.getValue());
order.setPayNo(payNo);
order.setPayTime(LocalDateTime.now());
int updated = orderMapper.updateById(order);
if (updated == 0) {
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR, "订单状态已变更,请重试");
}
}
}3.6 Token 机制幂等
java
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String TOKEN_PREFIX = "token:form:";
public String generateToken(String userId) {
String token = UUID.randomUUID().toString().replace("-", "");
String key = TOKEN_PREFIX + userId + ":" + token;
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(30));
return token;
}
public boolean validateAndDelete(String userId, String token) {
if (StringUtils.isBlank(token)) {
return false;
}
String key = TOKEN_PREFIX + userId + ":" + token;
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
}
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private TokenService tokenService;
@Autowired
private OrderService orderService;
@GetMapping("/token")
public ApiResponse<String> getToken() {
String userId = UserContext.getUserId();
return ApiResponse.success(tokenService.generateToken(userId));
}
@PostMapping
public ApiResponse<Long> createOrder(@RequestHeader("X-Token") String token,
@RequestBody @Validated CreateOrderRequest request) {
String userId = UserContext.getUserId();
if (!tokenService.validateAndDelete(userId, token)) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "请勿重复提交");
}
Long orderId = orderService.createOrder(request);
return ApiResponse.success(orderId);
}
}3.7 分布式锁幂等
java
@Component
public class DistributedLock {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean tryLock(String key, String value, Duration timeout) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(key, value, timeout)
);
}
public boolean unlock(String key, String value) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript, List.of(key), value);
return Long.valueOf(1).equals(result);
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key();
long timeout() default 30;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String message() default "请勿重复操作";
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private DistributedLock distributedLock;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
String key = parseKey(joinPoint, idempotent.key());
String value = UUID.randomUUID().toString();
Duration timeout = Duration.of(idempotent.timeout(), toChronoUnit(idempotent.timeUnit()));
if (!distributedLock.tryLock(key, value, timeout)) {
throw new BusinessException(ErrorCode.REPEAT_REQUEST, idempotent.message());
}
try {
return joinPoint.proceed();
} finally {
distributedLock.unlock(key, value);
}
}
private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < paramNames.length; i++) {
if (keyExpression.contains(paramNames[i])) {
return keyExpression.replace(paramNames[i], String.valueOf(args[i]));
}
}
return keyExpression;
}
private ChronoUnit toChronoUnit(TimeUnit timeUnit) {
return switch (timeUnit) {
case SECONDS -> ChronoUnit.SECONDS;
case MINUTES -> ChronoUnit.MINUTES;
case HOURS -> ChronoUnit.HOURS;
default -> ChronoUnit.SECONDS;
};
}
}
@Service
public class PaymentService {
@Idempotent(key = "payment:process:#orderId", timeout = 60, message = "支付处理中,请勿重复提交")
public PaymentResult processPayment(Long orderId, PaymentRequest request) {
return doProcessPayment(orderId, request);
}
}3.8 MQ 消费幂等
java
@Component
public class OrderMessageConsumer {
@Autowired
private OrderService orderService;
@Autowired
private MessageConsumeLogMapper consumeLogMapper;
@RabbitListener(queues = "order.create.queue")
public void handleOrderCreate(Message message) {
String messageId = message.getMessageProperties().getMessageId();
String messageBody = new String(message.getBody());
if (isConsumed(messageId)) {
log.warn("消息已消费,跳过: messageId={}", messageId);
return;
}
try {
OrderMessage orderMessage = JsonUtils.fromJson(messageBody, OrderMessage.class);
orderService.createOrder(orderMessage);
markConsumed(messageId, messageBody);
} catch (Exception e) {
log.error("消息处理失败: messageId={}", messageId, e);
throw e;
}
}
private boolean isConsumed(String messageId) {
return consumeLogMapper.selectByMessageId(messageId) != null;
}
private void markConsumed(String messageId, String messageBody) {
MessageConsumeLog log = MessageConsumeLog.builder()
.messageId(messageId)
.messageBody(messageBody)
.consumeTime(LocalDateTime.now())
.build();
consumeLogMapper.insert(log);
}
}
CREATE TABLE `message_consume_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`message_id` varchar(64) NOT NULL,
`message_body` text,
`consume_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_id` (`message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;四、实战场景
场景一:下单接口重复提交
用户重复点击"提交订单",如果没有幂等保护,就可能生成多笔订单。更合理的做法是业务单号幂等或状态机约束。
java
@Service
public class OrderService {
@Transactional
public Long createOrder(CreateOrderRequest request) {
Order existingOrder = orderMapper.selectByOrderNo(request.orderNo());
if (existingOrder != null) {
log.info("订单已存在,返回已有订单: orderNo={}, orderId={}",
request.orderNo(), existingOrder.getId());
return existingOrder.getId();
}
Order order = buildOrder(request);
orderMapper.insert(order);
return order.getId();
}
}场景二:参数非法但进入业务层
没有统一校验时,很多脏数据会一路流到数据库或第三方接口,最后才以更难排查的方式爆出来。
java
@RestController
@Validated
public class UserController {
@PostMapping("/users")
public ApiResponse<Long> createUser(@RequestBody @Validated(CreateGroup.class) UserRequest request) {
return ApiResponse.success(userService.create(request));
}
@GetMapping("/users/{id}")
public ApiResponse<User> getUser(@PathVariable @Min(1) Long id) {
return ApiResponse.success(userService.getById(id));
}
@GetMapping("/users")
public ApiResponse<PageResult<User>> listUsers(
@RequestParam(defaultValue = "1") @Min(1) Integer page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) Integer size) {
return ApiResponse.success(userService.list(page, size));
}
}场景三:日志里没有业务单号
线上报错时如果日志没有 traceId、订单号、用户 ID,排障就只能靠猜。
java
@Slf4j
@Service
public class OrderService {
public Order createOrder(CreateOrderRequest request) {
LogHelper.start("createOrder", Map.of("orderNo", request.orderNo()));
try {
Order order = doCreateOrder(request);
LogHelper.end(order.getOrderNo(), "success");
return order;
} catch (Exception e) {
LogHelper.error(request.orderNo(), e.getMessage(), e);
throw e;
}
}
public void payOrder(Long orderId, PaymentRequest request) {
Order order = orderMapper.selectById(orderId);
log.info("支付订单: traceId={}, orderId={}, orderNo={}, amount={}",
TraceContext.getTraceId(), orderId, order.getOrderNo(), request.amount());
try {
doPayOrder(order, request);
} catch (Exception e) {
log.error("支付失败: traceId={}, orderId={}, orderNo={}, error={}",
TraceContext.getTraceId(), orderId, order.getOrderNo(), e.getMessage(), e);
throw e;
}
}
}五、排查与治理思路
5.1 日志治理重点
- 关键链路统一 traceId
- 关键业务统一打印业务单号
- 错误日志保留必要上下文,但不泄露敏感信息
5.2 校验治理重点
- 基础校验前移到接口层
- 校验错误统一格式返回
- 不要把所有校验都塞到 Controller 里手写
5.3 幂等治理重点
- 重复请求要有唯一业务标识
- MQ 和回调场景必须幂等
- 优先使用唯一键、状态机等结果层约束
六、常见误区
- 日志只有异常栈,没有业务上下文
- 所有请求参数都原样打印,带来隐私和噪音问题
- 校验逻辑分散在各层,风格不一致
- 只做前端防抖,不做后端幂等
- 幂等设计只考虑正常流程,不考虑异常重试
- 分布式锁没有设置超时时间,可能导致死锁
七、面试补充
-
为什么日志不是打印越多越好
- 日志过多会影响性能,增加磁盘 IO
- 关键信息被淹没,排查效率降低
- 敏感信息泄露风险增加
-
参数校验为什么要尽量前移
- 尽早拦截非法输入,减少无效处理
- 避免脏数据污染业务逻辑
- 统一错误格式,提升用户体验
-
为什么重复请求问题最终还是要靠后端幂等兜底
- 前端防抖可被绕过(直接调用接口)
- 网络超时重试无法避免
- MQ 重复投递需要后端处理
-
日志、校验、幂等为什么都属于工程基础能力
- 影响系统稳定性和可维护性
- 是线上问题排查的关键手段
- 是分布式系统可靠性的基石
版本差异(日志/校验/幂等 → Spring Boot 3.5.x)
| 特性 | 旧实践 | 当前实践 |
|---|---|---|
| 日志 | Logback 默认 | 不变;Boot 3.x 默认 logback 1.5.x,支持结构化日志(JSON) |
| 参数校验 | javax.validation | jakarta.validation + Bean Validation 3.0(Boot 3 强制) |
| 幂等 | 数据库唯一键 / Redis | 不变;Redis 客户端推荐 spring-data-redis(Lettuce) |
| 链路追踪 | 手动 traceId | Micrometer Tracing + OpenTelemetry(Boot 3.4+ 集成) |
日志分级、参数校验、幂等设计(唯一键、token、状态机)等核心原则不随版本变化;差异集中在 jakarta 包名与链路追踪基础设施的标准化上。