【Java项目技术亮点】审计日志自动记录

写在前面

说实话,我见过太多系统出了生产事故后,老板问"谁改的?什么时候改的?改了什么?",开发团队只能摇头说"日志里没记"。这个坑我踩过------凌晨两点被叫起来查数据变更,翻遍了应用日志、数据库binlog,折腾了三个小时才定位到问题。从那以后,我经手的每个项目都必上审计日志,而且必须是自动的,不能靠人手动记。

文章目录


一、为什么需要审计日志?

1.1 场景引入

想象一下:早上刚到公司,产品经理冲过来说"昨天有一批用户积分被清零了,客服电话被打爆了"。你赶紧查代码、查数据库,发现是一条UPDATE语句没加WHERE条件------但谁执行的?什么时候执行的?从哪台机器发的请求?一概不知。

再想象一下:金融系统里,客户账户余额突然少了十万块。监管来查,要求你提供完整的操作记录。你没有,轻则罚款,重则停业整顿。

1.2 生活类比

审计日志就像银行流水。你每存一笔钱、取一笔钱、转一笔账,银行都记得清清楚楚------时间、金额、对方账户、余额变动。出了问题,拿流水一对,明明白白。

没有审计日志的系统,就像一个不收小票的便利店。钱少了,你都不知道是找错了还是被人拿了。

1.3 审计日志的核心价值

  • 合规要求:等保、ISO27001、GDPR等法规都要求操作留痕
  • 问题追溯:出了Bug能快速定位是谁、什么时候、改了什么
  • 操作留痕:防止内部人员恶意操作,有凭有据
  • 数据分析:分析高频操作、异常行为,辅助安全决策

二、审计日志的内容设计

2.1 基础字段设计

字段名 类型 说明 是否必填
id BIGINT 主键ID
operator_id BIGINT 操作人ID
operator_name VARCHAR(64) 操作人姓名
operation_type VARCHAR(32) 操作类型:CREATE/UPDATE/DELETE/QUERY
operation_module VARCHAR(64) 操作模块:用户管理/订单管理
operation_desc VARCHAR(255) 操作描述
request_url VARCHAR(512) 请求URL
request_method VARCHAR(16) 请求方法:GET/POST/PUT/DELETE
request_params TEXT 请求参数(JSON)
response_data TEXT 响应结果(JSON,可脱敏)
ip_address VARCHAR(64) 操作人IP地址
user_agent VARCHAR(512) 浏览器/客户端信息
execution_time INT 执行耗时(毫秒)
operation_result TINYINT 操作结果:0-失败,1-成功
error_msg TEXT 错误信息
create_time DATETIME 创建时间

2.2 扩展字段设计

根据业务需要,还可以加这些:

  • 变更前数据before_data):记录修改前的完整数据
  • 变更后数据after_data):记录修改后的完整数据
  • 业务类型biz_type):区分操作日志、安全日志、系统日志
  • 租户IDtenant_id):多租户系统需要
  • 链路追踪IDtrace_id):方便和SkyWalking/Zipkin打通

2.3 审计日志表DDL

sql 复制代码
CREATE TABLE `audit_log` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `operator_id` BIGINT DEFAULT NULL COMMENT '操作人ID',
  `operator_name` VARCHAR(64) DEFAULT NULL COMMENT '操作人姓名',
  `operation_type` VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE/QUERY/LOGIN/LOGOUT',
  `operation_module` VARCHAR(64) NOT NULL COMMENT '操作模块',
  `operation_desc` VARCHAR(255) NOT NULL COMMENT '操作描述',
  `request_url` VARCHAR(512) DEFAULT NULL COMMENT '请求URL',
  `request_method` VARCHAR(16) DEFAULT NULL COMMENT '请求方法',
  `request_params` TEXT COMMENT '请求参数JSON',
  `response_data` TEXT COMMENT '响应结果JSON',
  `ip_address` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址',
  `user_agent` VARCHAR(512) DEFAULT NULL COMMENT 'User-Agent',
  `execution_time` INT DEFAULT '0' COMMENT '执行耗时(ms)',
  `operation_result` TINYINT NOT NULL DEFAULT '1' COMMENT '操作结果:0-失败,1-成功',
  `error_msg` TEXT COMMENT '错误信息',
  `before_data` TEXT COMMENT '变更前数据JSON',
  `after_data` TEXT COMMENT '变更后数据JSON',
  `trace_id` VARCHAR(64) DEFAULT NULL COMMENT '链路追踪ID',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_operator_id` (`operator_id`),
  KEY `idx_operation_type` (`operation_type`),
  KEY `idx_module` (`operation_module`),
  KEY `idx_create_time` (`create_time`),
  KEY `idx_trace_id` (`trace_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志表';

踩坑提醒

我见过有人把request_params和response_data存成VARCHAR(4000),结果参数太长直接截断,关键信息丢了。这种字段必须用TEXT类型。另外,response_data建议做长度限制,比如最多存10KB,避免日志表被大响应撑爆。


三、AOP实现自动审计日志

3.1 @AuditLog自定义注解设计

java 复制代码
import java.lang.annotation.*;

/**
 * 审计日志注解
 * 标记在Controller方法上,自动记录操作日志
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {
    
    /**
     * 操作模块
     */
    String module();
    
    /**
     * 操作描述
     */
    String description();
    
    /**
     * 操作类型
     */
    OperationType type() default OperationType.QUERY;
    
    /**
     * 是否记录请求参数
     */
    boolean recordParams() default true;
    
    /**
     * 是否记录响应结果
     */
    boolean recordResponse() default false;
    
    /**
     * 需要脱敏的字段(支持SpEL表达式)
     */
    String[] sensitiveFields() default {};
}

操作类型枚举:

java 复制代码
public enum OperationType {
    CREATE("新增"),
    UPDATE("修改"),
    DELETE("删除"),
    QUERY("查询"),
    LOGIN("登录"),
    LOGOUT("登出"),
    EXPORT("导出"),
    IMPORT("导入");
    
    private final String desc;
    
    OperationType(String desc) {
        this.desc = desc;
    }
    
    public String getDesc() {
        return desc;
    }
}

3.2 Aspect切面实现

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.time.LocalDateTime;

@Slf4j
@Aspect
@Component
@Order(1) // 确保在事务注解之前执行
public class AuditLogAspect {
    
    private final AuditLogService auditLogService;
    
    public AuditLogAspect(AuditLogService auditLogService) {
        this.auditLogService = auditLogService;
    }
    
    @Around("@annotation(auditLog)")
    public Object around(ProceedingJoinPoint point, AuditLog auditLog) throws Throwable {
        // 记录开始时间
        long startTime = System.currentTimeMillis();
        
        // 获取请求信息
        ServletRequestAttributes attributes = 
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes != null ? attributes.getRequest() : null;
        
        // 构建日志对象
        AuditLogEntity logEntity = new AuditLogEntity();
        logEntity.setOperationType(auditLog.type().name());
        logEntity.setOperationModule(auditLog.module());
        logEntity.setOperationDesc(auditLog.description());
        logEntity.setCreateTime(LocalDateTime.now());
        
        if (request != null) {
            logEntity.setRequestUrl(request.getRequestURI());
            logEntity.setRequestMethod(request.getMethod());
            logEntity.setIpAddress(getClientIp(request));
            logEntity.setUserAgent(request.getHeader("User-Agent"));
        }
        
        // 记录请求参数
        if (auditLog.recordParams()) {
            String params = getRequestParams(point);
            logEntity.setRequestParams(params);
        }
        
        // 获取当前登录用户
        LoginUser loginUser = getCurrentUser();
        if (loginUser != null) {
            logEntity.setOperatorId(loginUser.getUserId());
            logEntity.setOperatorName(loginUser.getUserName());
        }
        
        Object result = null;
        try {
            // 执行目标方法
            result = point.proceed();
            
            // 记录成功
            logEntity.setOperationResult(1);
            
            // 记录响应(如果开启)
            if (auditLog.recordResponse() && result != null) {
                String responseStr = JsonUtils.toJson(result);
                // 限制长度,避免过大
                if (responseStr.length() > 10000) {
                    responseStr = responseStr.substring(0, 10000) + "...[truncated]";
                }
                logEntity.setResponseData(responseStr);
            }
            
        } catch (Throwable e) {
            // 记录失败
            logEntity.setOperationResult(0);
            logEntity.setErrorMsg(e.getMessage());
            throw e;
        } finally {
            // 计算耗时
            long executionTime = System.currentTimeMillis() - startTime;
            logEntity.setExecutionTime((int) executionTime);
            
            // 异步保存日志
            auditLogService.saveLogAsync(logEntity);
        }
        
        return result;
    }
    
    /**
     * 获取请求参数
     */
    private String getRequestParams(ProceedingJoinPoint point) {
        Object[] args = point.getArgs();
        if (args == null || args.length == 0) {
            return "{}";
        }
        
        // 过滤掉HttpServletRequest、HttpServletResponse等对象
        Object[] filteredArgs = java.util.Arrays.stream(args)
            .filter(arg -> !(arg instanceof javax.servlet.ServletRequest))
            .filter(arg -> !(arg instanceof javax.servlet.ServletResponse))
            .filter(arg -> !(arg instanceof org.springframework.web.multipart.MultipartFile))
            .toArray();
        
        try {
            return JsonUtils.toJson(filteredArgs);
        } catch (Exception e) {
            return "参数序列化失败: " + e.getMessage();
        }
    }
    
    /**
     * 获取客户端真实IP
     */
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        // 多个代理情况,取第一个IP
        if (ip != null && ip.contains(",")) {
            ip = ip.split(",")[0].trim();
        }
        return ip;
    }
    
    /**
     * 获取当前登录用户(根据你的权限框架调整)
     */
    private LoginUser getCurrentUser() {
        try {
            // Spring Security
            Object principal = org.springframework.security.core.context.SecurityContextHolder
                .getContext().getAuthentication().getPrincipal();
            if (principal instanceof LoginUser) {
                return (LoginUser) principal;
            }
        } catch (Exception e) {
            log.debug("获取当前用户失败", e);
        }
        return null;
    }
}

3.3 获取当前登录用户

不同权限框架的获取方式:

权限框架 获取方式
Spring Security SecurityContextHolder.getContext().getAuthentication()
Sa-Token StpUtil.getLoginId() / StpUtil.getSession()
Apache Shiro SecurityUtils.getSubject().getPrincipal()
自定义JWT 从Request Header解析token,再查缓存/数据库

ThreadLocal方式(自定义场景):

java 复制代码
public class UserContext {
    private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
    
    public static void setUser(LoginUser user) {
        USER_HOLDER.set(user);
    }
    
    public static LoginUser getUser() {
        return USER_HOLDER.get();
    }
    
    public static void clear() {
        USER_HOLDER.remove();
    }
}

// 在拦截器中设置
public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("Authorization");
        LoginUser user = tokenService.parseToken(token);
        UserContext.setUser(user);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear(); // 必须清理,防止内存泄漏
    }
}

3.4 异步保存日志

java 复制代码
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class AuditLogServiceImpl implements AuditLogService {
    
    private final AuditLogMapper auditLogMapper;
    
    public AuditLogServiceImpl(AuditLogMapper auditLogMapper) {
        this.auditLogMapper = auditLogMapper;
    }
    
    /**
     * 异步保存审计日志
     * 使用Spring的@Async,避免影响主流程响应时间
     */
    @Async("auditLogExecutor")
    @Override
    public void saveLogAsync(AuditLogEntity logEntity) {
        try {
            auditLogMapper.insert(logEntity);
        } catch (Exception e) {
            // 日志保存失败不能影响主业务
            log.error("审计日志保存失败: {}", e.getMessage(), e);
        }
    }
}

线程池配置:

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean("auditLogExecutor")
    public ThreadPoolTaskExecutor auditLogExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("audit-log-");
        // 拒绝策略:CallerRunsPolicy,让调用线程自己执行
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }
}

3.5 完整使用示例

java 复制代码
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/user")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @PostMapping
    @AuditLog(module = "用户管理", description = "新增用户", type = OperationType.CREATE)
    public Result<Long> createUser(@RequestBody UserDTO userDTO) {
        Long userId = userService.createUser(userDTO);
        return Result.success(userId);
    }
    
    @PutMapping("/{id}")
    @AuditLog(module = "用户管理", description = "修改用户", type = OperationType.UPDATE)
    public Result<Void> updateUser(@PathVariable Long id, @RequestBody UserDTO userDTO) {
        userService.updateUser(id, userDTO);
        return Result.success();
    }
    
    @DeleteMapping("/{id}")
    @AuditLog(module = "用户管理", description = "删除用户", type = OperationType.DELETE)
    public Result<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return Result.success();
    }
    
    @GetMapping("/{id}")
    @AuditLog(module = "用户管理", description = "查询用户详情", type = OperationType.QUERY, 
              recordParams = false, recordResponse = false)
    public Result<UserVO> getUser(@PathVariable Long id) {
        return Result.success(userService.getUserById(id));
    }
}

四、审计日志的进阶设计

4.1 数据变更对比

记录修改前和修改后的值,是审计日志的进阶能力。实现方式有两种:

方式一:在Service层手动记录(精确但侵入性强)

java 复制代码
@Service
public class UserServiceImpl implements UserService {
    
    private final UserMapper userMapper;
    private final AuditLogService auditLogService;
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateUser(Long id, UserDTO dto) {
        // 1. 查询修改前的数据
        User oldUser = userMapper.selectById(id);
        String beforeData = JsonUtils.toJson(oldUser);
        
        // 2. 执行修改
        User updateUser = new User();
        BeanUtils.copyProperties(dto, updateUser);
        updateUser.setId(id);
        userMapper.updateById(updateUser);
        
        // 3. 查询修改后的数据
        User newUser = userMapper.selectById(id);
        String afterData = JsonUtils.toJson(newUser);
        
        // 4. 手动发送审计日志(带变更对比)
        auditLogService.saveWithDiff(id, "用户管理", "修改用户", 
            OperationType.UPDATE, beforeData, afterData);
    }
}

方式二:AOP自动对比(推荐,零侵入)

java 复制代码
@Around("@annotation(auditLog)")
public Object aroundWithDiff(ProceedingJoinPoint point, AuditLog auditLog) throws Throwable {
    // 判断是否需要记录变更对比
    if (auditLog.type() == OperationType.UPDATE && auditLog.recordDiff()) {
        // 从参数中提取ID
        Long entityId = extractIdFromArgs(point);
        
        // 查询修改前的数据
        Object beforeData = queryBeforeData(entityId);
        
        // 执行方法
        Object result = point.proceed();
        
        // 查询修改后的数据
        Object afterData = queryAfterData(entityId);
        
        // 记录差异
        logEntity.setBeforeData(JsonUtils.toJson(beforeData));
        logEntity.setAfterData(JsonUtils.toJson(afterData));
        
        return result;
    }
    
    return point.proceed();
}

4.2 敏感数据脱敏

审计日志里不能存明文密码、身份证号、银行卡号。脱敏策略:

数据类型 脱敏规则 示例
手机号 中间四位隐藏 138****8000
身份证号 中间十二位隐藏 110101********1234
银行卡号 中间隐藏,保留前后四位 6222 **** **** 8888
密码 全部替换为****** ******
邮箱 用户名部分隐藏 a***@qq.com

脱敏工具类:

java 复制代码
public class DesensitizeUtils {
    
    /**
     * 手机号脱敏
     */
    public static String mobile(String mobile) {
        if (mobile == null || mobile.length() != 11) {
            return mobile;
        }
        return mobile.substring(0, 3) + "****" + mobile.substring(7);
    }
    
    /**
     * 身份证号脱敏
     */
    public static String idCard(String idCard) {
        if (idCard == null || idCard.length() != 18) {
            return idCard;
        }
        return idCard.substring(0, 6) + "************" + idCard.substring(14);
    }
    
    /**
     * 通用字符串脱敏
     */
    public static String mask(String str, int prefix, int suffix) {
        if (str == null || str.length() <= prefix + suffix) {
            return str;
        }
        StringBuilder sb = new StringBuilder();
        sb.append(str, 0, prefix);
        for (int i = 0; i < str.length() - prefix - suffix; i++) {
            sb.append("*");
        }
        sb.append(str.substring(str.length() - suffix));
        return sb.toString();
    }
    
    /**
     * 对JSON字符串中的指定字段脱敏
     */
    public static String desensitizeJson(String json, String... fields) {
        try {
            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
            com.fasterxml.jackson.databind.node.ObjectNode node = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json);
            
            for (String field : fields) {
                if (node.has(field)) {
                    String value = node.get(field).asText();
                    node.put(field, mask(value, 2, 2));
                }
            }
            
            return mapper.writeValueAsString(node);
        } catch (Exception e) {
            return json;
        }
    }
}

在切面中使用脱敏:

java 复制代码
// 对请求参数脱敏
String params = getRequestParams(point);
if (auditLog.sensitiveFields().length > 0) {
    params = DesensitizeUtils.desensitizeJson(params, auditLog.sensitiveFields());
}
logEntity.setRequestParams(params);

4.3 日志分级

不是所有操作都需要同等级记录:

日志级别 说明 示例 保存时长
操作日志 普通业务操作 查询列表、查看详情 3个月
安全日志 涉及权限变更 登录、登出、修改密码、分配角色 1年
数据变更日志 增删改操作 新增订单、修改金额、删除用户 2年
系统日志 系统级事件 定时任务执行、配置变更 1个月

实现方式:在注解中增加level属性,保存时根据级别路由到不同的表或存储介质。

4.4 日志存储策略

审计日志量很大,单一MySQL扛不住。推荐分层存储:

复制代码
热数据(最近7天) → MySQL
温数据(7天~3个月) → MySQL归档表(按时间分表)
冷数据(3个月以上) → Elasticsearch 或 OSS/S3

MySQL按时间分表示例:

sql 复制代码
-- 按月分表:audit_log_202401, audit_log_202402...
-- 查询时根据时间范围路由到对应表

ES存储优势:

  • 全文检索:根据操作描述、错误信息模糊搜索
  • 聚合分析:统计高频操作、异常操作趋势
  • 横向扩展:数据量大了加节点就行

五、踩坑指南

5.1 AOP拦截导致的事务问题

踩坑提醒

这个坑我踩过------审计日志切面在事务提交前就执行了,结果主业务回滚了,审计日志却保存了"操作成功"。或者反过来,审计日志保存失败,把主业务事务也搞回滚了。

解决方案:

  1. 切面加@Order(1)确保在事务之前执行
  2. 日志保存用@Async异步,且捕获所有异常
  3. 如果需要精确记录事务结果,用TransactionSynchronizationManager注册回调
java 复制代码
// 在事务提交后才记录日志
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            auditLogService.saveLogAsync(logEntity);
        }
        
        @Override
        public void afterCompletion(int status) {
            if (status == STATUS_ROLLED_BACK) {
                logEntity.setOperationResult(0);
                logEntity.setErrorMsg("事务回滚");
                auditLogService.saveLogAsync(logEntity);
            }
        }
    }
);

5.2 异步保存日志的失败处理

异步保存失败怎么办?日志丢了就丢了?不行。

解决方案:

  1. 本地队列缓冲:内存队列 + 定时落盘
  2. 降级到本地文件:MQ/DB挂了,写本地日志文件
  3. 告警通知:连续失败N次,发钉钉/企业微信告警
java 复制代码
@Async("auditLogExecutor")
public void saveLogAsync(AuditLogEntity logEntity) {
    try {
        auditLogMapper.insert(logEntity);
    } catch (Exception e) {
        log.error("审计日志保存失败,降级到本地文件: {}", e.getMessage());
        // 降级:写入本地文件
        writeToLocalFile(logEntity);
        
        // 告警
        if (failCount.incrementAndGet() > 10) {
            alertService.sendAlert("审计日志连续保存失败,请检查数据库连接");
        }
    }
}

5.3 循环调用导致的日志爆炸

踩坑提醒

我见过一个项目,A服务调用B服务,B服务调用C服务,每个服务都有审计日志。一个请求下来,日志表里插了十几条记录,而且内容高度重复。更惨的是,有个定时任务每分钟执行一次,每次产生上千条日志,三天就把磁盘撑满了。

解决方案:

  1. 入口层记录:只在Controller层记录,Service层不记
  2. 接口白名单:定时任务、健康检查接口不记日志
  3. 采样记录:查询类接口按1%采样记录
java 复制代码
@Around("@annotation(auditLog)")
public Object around(ProceedingJoinPoint point, AuditLog auditLog) throws Throwable {
    // 白名单过滤
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
        .getRequestAttributes()).getRequest();
    if (isInWhitelist(request.getRequestURI())) {
        return point.proceed();
    }
    
    // 查询接口采样(1%概率记录)
    if (auditLog.type() == OperationType.QUERY && Math.random() > 0.01) {
        return point.proceed();
    }
    
    // ... 正常记录逻辑
}

5.4 审计日志本身的性能影响

审计日志再轻量,也是有开销的。在高并发场景下:

  • 参数序列化(大对象尤其慢)
  • 数据库插入(磁盘IO)
  • 网络传输(如果走远程日志服务)

优化手段:

优化点 方案 效果
序列化 用Jackson代替Fastjson,开启Afterburner 提升30%+
批量写入 内存队列缓冲,批量INSERT 提升10倍+
异步化 @Async + 独立线程池 主流程零阻塞
存储 热数据MySQL + 冷数据ES 查询和写入分离
java 复制代码
// 批量写入示例
@Component
public class AuditLogBuffer {
    
    private final List<AuditLogEntity> buffer = new ArrayList<>();
    private static final int BATCH_SIZE = 100;
    
    @Scheduled(fixedRate = 5000) // 每5秒刷盘一次
    public void flush() {
        List<AuditLogEntity> toFlush;
        synchronized (buffer) {
            if (buffer.isEmpty()) return;
            toFlush = new ArrayList<>(buffer);
            buffer.clear();
        }
        
        // 批量插入
        auditLogMapper.batchInsert(toFlush);
    }
    
    public void add(AuditLogEntity log) {
        synchronized (buffer) {
            buffer.add(log);
            if (buffer.size() >= BATCH_SIZE) {
                flush();
            }
        }
    }
}

六、问题与解答

Q1:审计日志量太大,MySQL扛不住怎么办?

A: 分三步走:

  1. 短期:加索引、优化SQL、异步批量写入
  2. 中期:按时间分表(如按月),定期归档老数据
  3. 长期:热数据MySQL + 冷数据Elasticsearch,查询走ES,统计走MySQL

说实话,绝大多数公司到不了"长期"那一步。日活百万以下的系统,MySQL+分表完全够用。

Q2:审计日志需要记录查询操作吗?会不会太多?

A: 看业务。金融、政务类系统,查询也要记,因为"谁看了什么数据"也是敏感信息。普通业务系统,查询可以采样记录(比如只记1%),或者只记敏感数据的查询(如用户身份证号、银行卡信息)。

我的建议是:增删改必须全量记,查询按需记。

Q3:审计日志的保存时长有什么规范要求?

A: 不同行业要求不同:

  • 金融行业:一般要求至少保存5年
  • 医疗行业:病历相关操作通常要求永久保存
  • 等保三级:要求审计记录保存至少6个月
  • 一般企业:建议操作日志3个月,安全日志1年

具体看你的合规要求。超期的数据可以归档到廉价存储(如OSS),不要直接删。


七、面试高频考点汇总

面试题1:审计日志怎么实现不侵入业务代码?

答案: 用Spring AOP + 自定义注解。定义@AuditLog注解标记在Controller方法上,通过@Around切面拦截方法执行,在方法前后自动记录操作人、操作时间、请求参数、响应结果等信息。业务代码完全无感知。

面试题2:审计日志保存失败怎么办?会影响主业务吗?

答案: 不能影响主业务。实现方式:① 用@Async异步保存,主流程不等待;② 切面中try-catch所有异常;③ 进一步降级,数据库失败时写入本地文件;④ 连续失败触发告警通知运维。

面试题3:怎么保证审计日志和事务的一致性?

答案: 如果要求严格一致(事务回滚时日志也要标记失败),可以用TransactionSynchronizationManager.registerSynchronization()注册事务回调,在afterCommitafterCompletion中根据事务状态决定日志内容。但通常审计日志不要求强一致,异步保存即可。

面试题4:审计日志里的敏感数据怎么处理?

答案: 三种手段:① 存储前脱敏,如手机号显示为138****8000;② 不存储敏感字段,如密码直接不记录;③ 加密存储,对身份证号等用AES加密后入库,查询时解密。推荐组合使用:脱敏+加密。

面试题5:高并发下审计日志的性能优化手段有哪些?

答案: ① 异步化:@Async + 独立线程池,避免阻塞主流程;② 批量写入:内存队列缓冲,攒一批再批量INSERT;③ 采样记录:查询类接口按概率采样;④ 存储分层:热数据MySQL、冷数据ES或OSS;⑤ 参数精简:只记录关键字段,限制响应数据长度。


八、模拟面试官提问和参考答案

场景题1:设计一个审计日志系统,要求支持每天千万级日志写入,且能按操作人、时间、模块快速查询。

参考答案:

写入端:采用异步批量写入。Controller层AOP拦截,日志对象放入内存队列(如Disruptor),消费者批量写入MySQL或直写Kafka。

存储端:热数据(7天内)存MySQL按天分表;温数据(7天~3个月)存MySQL按月分表;冷数据(3个月以上)通过定时任务迁移到Elasticsearch。

查询端:近期查询走MySQL,带分页;复杂检索、聚合统计走ES。操作人、时间、模块都建索引。

场景题2:一个微服务架构系统,有10个服务,怎么统一收集审计日志?

参考答案:

方案一(推荐):每个服务独立记录到本地Kafka Topic,由独立的日志服务消费落盘。好处是服务间解耦,日志格式统一由日志服务控制。

方案二:每个服务直接写共享的日志数据库/ES。简单但耦合度高,日志服务挂了会影响所有服务。

方案三:Sidecar模式(Service Mesh),由Istio/Envoy统一拦截记录。最干净,但架构复杂度高。

场景题3:用户投诉说"我没删这条数据",你怎么用审计日志自证清白?

参考答案:

第一步:根据数据ID查审计日志,找到所有DELETE操作记录。第二步:核对操作人ID、IP地址、操作时间。第三步:如果操作人是该用户本人,且IP是他常用IP,时间也吻合,那说明是他自己操作的。第四步:如果操作人是系统账号,查对应的定时任务或自动化脚本记录。第五步:把完整的操作时间线(谁、什么时候、从哪、做了什么)反馈给用户和客服。

场景题4:审计日志表数据量达到亿级,查询慢怎么优化?

参考答案:

短期:给高频查询字段加索引(operator_id、create_time、operation_module)。中期:按时间分区或分表,查询时带上时间范围裁剪数据量。长期:迁移到Elasticsearch,利用倒排索引和分片提升查询性能。另外,可以预聚合热点数据(如每日操作统计),查询统计时直接读聚合表。

场景题5:如果审计日志被人恶意删了,怎么办?

参考答案:

这是个安全设计问题。首先,审计日志表应该只有专用服务有写入权限,应用服务只有读取权限。其次,数据库层面开启binlog,即使日志表被删也能从binlog恢复。再次,关键日志可以同步到独立的日志服务器或WORM(一次写入多次读取)存储,物理上不可删除。最后,对日志删除操作本身也要记录审计日志------"谁删除了审计日志"。


九、互动话题

你们项目的审计日志是怎么实现的?是AOP自动记录还是手动在代码里埋点?有没有遇到过日志量太大把数据库撑爆的情况?或者因为没记审计日志,出了事故后查不到线索的惨痛经历?评论区聊聊。


十、参考资料

Spring官方文档 - Aspect Oriented Programming