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

文章目录
-
- 一、为什么需要审计日志?
-
- [1.1 场景引入](#1.1 场景引入)
- [1.2 生活类比](#1.2 生活类比)
- [1.3 审计日志的核心价值](#1.3 审计日志的核心价值)
- 二、审计日志的内容设计
-
- [2.1 基础字段设计](#2.1 基础字段设计)
- [2.2 扩展字段设计](#2.2 扩展字段设计)
- [2.3 审计日志表DDL](#2.3 审计日志表DDL)
- 三、AOP实现自动审计日志
-
- [3.1 @AuditLog自定义注解设计](#3.1 @AuditLog自定义注解设计)
- [3.2 Aspect切面实现](#3.2 Aspect切面实现)
- [3.3 获取当前登录用户](#3.3 获取当前登录用户)
- [3.4 异步保存日志](#3.4 异步保存日志)
- [3.5 完整使用示例](#3.5 完整使用示例)
- 四、审计日志的进阶设计
-
- [4.1 数据变更对比](#4.1 数据变更对比)
- [4.2 敏感数据脱敏](#4.2 敏感数据脱敏)
- [4.3 日志分级](#4.3 日志分级)
- [4.4 日志存储策略](#4.4 日志存储策略)
- 五、踩坑指南
-
- [5.1 AOP拦截导致的事务问题](#5.1 AOP拦截导致的事务问题)
- [5.2 异步保存日志的失败处理](#5.2 异步保存日志的失败处理)
- [5.3 循环调用导致的日志爆炸](#5.3 循环调用导致的日志爆炸)
- [5.4 审计日志本身的性能影响](#5.4 审计日志本身的性能影响)
- 六、问题与解答
- 七、面试高频考点汇总
- 八、模拟面试官提问和参考答案
- 九、互动话题
- 十、参考资料
一、为什么需要审计日志?
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):区分操作日志、安全日志、系统日志 - 租户ID (
tenant_id):多租户系统需要 - 链路追踪ID (
trace_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拦截导致的事务问题
踩坑提醒
这个坑我踩过------审计日志切面在事务提交前就执行了,结果主业务回滚了,审计日志却保存了"操作成功"。或者反过来,审计日志保存失败,把主业务事务也搞回滚了。
解决方案:
- 切面加
@Order(1)确保在事务之前执行 - 日志保存用
@Async异步,且捕获所有异常 - 如果需要精确记录事务结果,用
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 异步保存日志的失败处理
异步保存失败怎么办?日志丢了就丢了?不行。
解决方案:
- 本地队列缓冲:内存队列 + 定时落盘
- 降级到本地文件:MQ/DB挂了,写本地日志文件
- 告警通知:连续失败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服务,每个服务都有审计日志。一个请求下来,日志表里插了十几条记录,而且内容高度重复。更惨的是,有个定时任务每分钟执行一次,每次产生上千条日志,三天就把磁盘撑满了。
解决方案:
- 入口层记录:只在Controller层记录,Service层不记
- 接口白名单:定时任务、健康检查接口不记日志
- 采样记录:查询类接口按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: 分三步走:
- 短期:加索引、优化SQL、异步批量写入
- 中期:按时间分表(如按月),定期归档老数据
- 长期:热数据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()注册事务回调,在afterCommit或afterCompletion中根据事务状态决定日志内容。但通常审计日志不要求强一致,异步保存即可。
面试题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自动记录还是手动在代码里埋点?有没有遇到过日志量太大把数据库撑爆的情况?或者因为没记审计日志,出了事故后查不到线索的惨痛经历?评论区聊聊。