1. 为什么需要Web日志切面?
在开发Web应用时,记录请求和响应日志是最基础的需求之一。想象一下,当线上出现问题时,如果没有详细的日志记录,排查问题就像在黑暗中摸索。传统的做法是在每个Controller方法中手动打印日志,但这样会导致大量重复代码,维护起来非常麻烦。
我遇到过这样一个场景:一个电商系统的订单接口突然出现异常,但由于缺乏详细的请求参数记录,花了整整一天才定位到是某个特定用户提交了异常数据。如果当时有完整的请求日志,可能10分钟就能解决问题。
SpringBoot的AOP(面向切面编程)完美解决了这个问题。通过@Aspect注解,我们可以定义一个切面类,在方法执行前后自动插入日志逻辑。这样不仅减少了重复代码,还能确保所有接口的日志格式统一。
2. 基础日志实现
2.1 最小化切面配置
先来看一个最简单的日志切面实现。这个版本会记录请求URL、方法名和参数:
@Slf4j @Aspect @Component public class BasicLogAspect { @Pointcut("execution(* com.example.controller..*.*(..))") public void webLog() {} @Around("webLog()") public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 获取请求信息 HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); // 记录请求信息 log.info("请求URL: {}", request.getRequestURL()); log.info("HTTP方法: {}", request.getMethod()); log.info("请求参数: {}", Arrays.toString(joinPoint.getArgs())); // 执行目标方法 Object result = joinPoint.proceed(); // 记录响应信息 log.info("响应结果: {}", result); return result; } }这个基础版本虽然简单,但已经能解决80%的日常需求。我在多个项目中实践后发现,即使是这么简单的日志记录,也能大幅提升问题排查效率。
2.2 获取更详细的请求信息
实际项目中,我们通常需要更详细的信息。比如客户端IP、请求头等。下面是增强版的实现:
@Before("webLog()") public void doBefore(JoinPoint joinPoint) { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); // 获取请求头 Enumeration<String> headers = request.getHeaderNames(); while(headers.hasMoreElements()) { String name = headers.nextElement(); log.info("请求头 {}: {}", name, request.getHeader(name)); } // 获取请求参数 Map<String, String[]> params = request.getParameterMap(); params.forEach((k,v) -> log.info("参数 {}: {}", k, String.join(",", v))); }这里使用了@Before注解,在方法执行前记录信息。特别注意获取请求头的方式,这在调试跨域等问题时特别有用。
3. 性能优化:异步日志处理
3.1 同步日志的性能瓶颈
在压力测试中,我发现当QPS达到1000以上时,同步写日志会导致明显的性能下降。这是因为日志IO操作会阻塞业务线程,特别是在使用文件或数据库存储日志时。
测试数据显示,同步日志会使接口响应时间增加20-50ms。对于高并发系统,这个开销是不可接受的。
3.2 引入线程池实现异步
Spring的@Async注解可以轻松实现异步处理。首先在配置类启用异步支持:
@Configuration @EnableAsync public class AsyncConfig { @Bean public Executor logExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("log-async-"); executor.initialize(); return executor; } }然后改造切面类:
@Async("logExecutor") @Around("webLog()") public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 日志记录逻辑 }经过优化后,日志操作不再阻塞主线程。实测在高并发场景下,接口响应时间几乎不受影响。
3.3 异步处理的注意事项
- 线程池配置:要根据系统负载合理设置线程池大小。我一般设置corePoolSize为CPU核心数+1
- 异常处理:异步方法抛出的异常不会被主线程捕获,需要在切面内部处理
- 上下文传递:异步线程会丢失MDC等线程绑定的上下文信息,需要手动传递
4. 数据安全:敏感信息脱敏
4.1 为什么需要脱敏?
在一次安全审计中,我们发现日志中完整记录了用户的身份证号和银行卡号,这违反了GDPR等数据保护法规。于是紧急开发了日志脱敏功能。
4.2 基于注解的脱敏方案
首先定义一个脱敏注解:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface Sensitive { SensitiveType type(); } public enum SensitiveType { ID_CARD, PHONE, BANK_CARD, EMAIL }然后实现脱敏工具类:
public class SensitiveUtil { public static String desensitize(String str, SensitiveType type) { switch(type) { case ID_CARD: return str.replaceAll("(\\d{4})\\d{10}(\\w{4})", "$1****$2"); case PHONE: return str.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"); // 其他类型处理 default: return str; } } }最后在切面中应用脱敏:
Object result = joinPoint.proceed(); if(result != null) { // 反射获取字段 Field[] fields = result.getClass().getDeclaredFields(); for(Field field : fields) { if(field.isAnnotationPresent(Sensitive.class)) { field.setAccessible(true); Object value = field.get(result); if(value != null) { String masked = SensitiveUtil.desensitize( value.toString(), field.getAnnotation(Sensitive.class).type()); field.set(result, masked); } } } }4.3 性能优化建议
反射操作会影响性能,可以通过以下方式优化:
- 使用缓存存储类的元数据信息
- 预编译脱敏正则表达式
- 对于高频访问的类,考虑使用字节码增强技术
5. 日志存储:从控制台到ELK
5.1 控制台日志的局限性
开发环境使用控制台输出日志很方便,但在生产环境会遇到问题:
- 日志分散在多台服务器
- 无法进行全文搜索
- 历史日志难以归档
5.2 集成ELK方案
ELK(Elasticsearch + Logstash + Kibana)是流行的日志管理方案。改造切面将日志发送到Logstash:
@Autowired private RestHighLevelClient elasticsearchClient; @AfterReturning(pointcut="webLog()", returning="result") public void afterReturning(JoinPoint joinPoint, Object result) { // 构建日志文档 Map<String, Object> doc = new HashMap<>(); doc.put("timestamp", new Date()); doc.put("method", joinPoint.getSignature().getName()); // 其他字段... // 异步写入ES elasticsearchClient.indexAsync( new IndexRequest("web-log").source(doc), RequestOptions.DEFAULT, new ActionListener<>() { @Override public void onResponse(IndexResponse r) {} @Override public void onFailure(Exception e) { log.error("ES写入失败", e); } }); }5.3 存储优化技巧
- 索引设计:按日期创建索引(如web-log-2023-08-01),方便管理
- 字段映射:为常用字段设置合适的类型,比如timestamp设为date
- 批量写入:使用Bulk API减少网络开销
6. 高级功能:链路追踪与性能监控
6.1 添加TraceID
在微服务架构中,一个请求可能经过多个服务。为每个请求分配唯一TraceID非常重要:
@Around("webLog()") public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { String traceId = UUID.randomUUID().toString(); MDC.put("traceId", traceId); try { return joinPoint.proceed(); } finally { MDC.remove("traceId"); } }在logback配置中引用TraceID:
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %X{traceId} %logger{50} - %msg%n</pattern>6.2 接口耗时统计
性能监控是生产环境的重要需求。我们可以扩展切面来记录接口耗时:
@Around("webLog()") public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost = System.currentTimeMillis() - start; log.info("接口耗时: {}ms", cost); // 推送到监控系统 metricsClient.record("api.cost", cost); } }6.3 告警机制
对于慢查询,可以设置自动告警:
if(cost > 1000) { // 超过1秒 alertService.sendAlert("慢接口告警", joinPoint.getSignature() + "耗时" + cost + "ms"); }7. 最佳实践与常见问题
7.1 生产环境配置建议
- 日志级别:生产环境使用INFO级别,避免DEBUG日志影响性能
- 采样率:在高流量系统中,可以考虑采样记录(如只记录1%的请求)
- 敏感字段:确保密码、token等字段永远不会被记录
7.2 常见问题排查
问题1:切面不生效
- 检查是否添加了
@EnableAspectJAutoProxy - 确认切点表达式匹配到了目标方法
问题2:获取不到HttpServletRequest
- 确保请求是通过DispatcherServlet处理的
- 异步场景需要手动传递RequestAttributes
问题3:循环依赖
- 切面不要依赖会被自己拦截的Bean
- 使用
@Lazy注解解决循环依赖
7.3 性能测试数据
以下是在不同场景下的性能对比(测试环境:4核8G,SpringBoot 2.7):
| 场景 | QPS | 平均响应时间 | CPU使用率 |
|---|---|---|---|
| 无日志 | 12000 | 23ms | 65% |
| 同步日志 | 8500 | 45ms | 82% |
| 异步日志 | 11500 | 25ms | 68% |
可以看到,异步日志方案几乎不影响系统性能。