{T}

日志校验与幂等最佳实践

概念与背景

很多项目表面上"功能能跑",但一到线上就暴露出三个大坑:

  • 日志打不出关键上下文
  • 参数校验不统一
  • 重复请求和重复消息没有幂等保护

这三个问题分散在不同层,但本质都属于工程稳定性基础设施。

一、日志最佳实践

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邮箱格式@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.validationjakarta.validation + Bean Validation 3.0(Boot 3 强制)
幂等数据库唯一键 / Redis不变;Redis 客户端推荐 spring-data-redis(Lettuce)
链路追踪手动 traceIdMicrometer Tracing + OpenTelemetry(Boot 3.4+ 集成)

日志分级、参数校验、幂等设计(唯一键、token、状态机)等核心原则不随版本变化;差异集中在 jakarta 包名与链路追踪基础设施的标准化上。