企业实践中操作日志解决方案(ElasticSearch+RabbitMQ)

210 阅读6分钟

背景与需求

在企业应用中,操作日志是不可或缺的一部分,用于记录用户和系统的行为操作,包括新增、修改、删除等操作。这些日志不仅是审计和合规的需求,还能为问题追踪和行为分析提供关键数据。首先确定一点,需要什么:

  • 需要记录大量的操作日志,同时支持快速查询。
  • 需要按业务ID、操作类型、时间范围等条件检索日志。
  • 支持海量日志数据的分页查询,并按时间或其他字段排序。

解决方案概述

重新建立日志表和ES对比

优缺点Elasticsearch传统关系型数据库表
查询效率高效的全文搜索和聚合分析,支持大规模日志的快速查询查询性能受限于索引和表设计,大数据量时可能较慢
扩展性高扩展性,支持分布式部署和水平扩展水平扩展难度大,需要分库分表或复杂的分片设计
实时性支持近实时的数据写入与查询一般存在一定的延迟,尤其在大量数据写入时
易用性配置和维护较为复杂,需要精心设计索引和查询结构比较简单,但在数据量大时可能需要优化查询
存储要求对内存和存储要求较高,需要较多资源支持存储要求相对较低,适合存储结构化数据
数据一致性最终一致性模型,不保证强一致性强一致性,适合需要事务和严格数据一致性的场景

考虑到有多个服务都需要用到日志写入,与此同时公司正好CRM也是使用ElasticSearch,那决定就是你了。

本方案基于 RabbitMQElasticsearch (ES) ,采用消息队列异步处理日志写入操作,借助 Elasticsearch 提供高效的日志存储和搜索功能,实现了以下核心功能:

  1. 异步记录日志:通过 RabbitMQ 实现异步日志写入,提升系统性能,避免对主业务流程的阻塞。
  2. 高效搜索与过滤:借助 Elasticsearch 的强大搜索能力,实现复杂条件查询。
  3. 日志去重与排序:在日志查询结果中,通过自定义逻辑实现去重与排序。

核心实现

1. 日志记录

日志记录的核心代码如下,通过 convertAndSendObject 方法,将日志信息封装为 LogDto 对象,并异步发送到 RabbitMQ:

public void convertAndSendObject(String index, Long businessId, Integer client, String operationTypeName, String remark) {
    if (!StringUtils.isBlank(index)) {
        LogDto logDto = new LogDto();
        logDto.setBusinessId(businessId);
        logDto.setIndex(index);
        logDto.setOperationTypeName(operationTypeName);
        if (client != null) {
            logDto.setOperationTypeName((client == 1 ? "用户端" : "管理端") + operationTypeName);
        }

        logDto.setRemark(remark);
        logDto.setGmtCreate(new Date());
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            HttpServletRequest httpServletRequest = attributes.getRequest();
            logDto.setIp(BaseSysUtils.getClientIP(httpServletRequest));
        }

        OpenUserDetails openUserDetails = OpenHelper.getUserMayEmpty();
        if (openUserDetails != null) {
            logDto.setUserId(openUserDetails.getUserId());
            logDto.setNickName(openUserDetails.getNickName());
        } else {
            logDto.setUserId(-1L);
            logDto.setNickName("系统");
        }

        if (StrUtil.isBlank(logDto.getNickName())) {
            logDto.setNickName("系统");
        }

        this.rabbitTemplate.convertAndSend("operation_record_logs", logDto);
    }
}

通过 RabbitMQ,将日志异步发送到日志存储的消费者端,避免了直接写入数据库可能导致的性能瓶颈。

在每一个Service方法的修改后调用该方法即可,底层原理是依据RabbitMQ将logDto对象传递给消费者,消费者再解析存储到ES中;消费者示例代码如下

@Component
public class LogMessageConsumer {

    @Autowired
    private RestHighLevelClient restHighLevelClient;  // Elasticsearch 客户端
    @Autowired
    private ObjectMapper objectMapper;  // 用于将 JSON 转换为 LogDto 对象

    @RabbitListener(queues = "operation_record_logs")  // 监听 MQ 队列
    public void consumeLogMessage(String message) {
        try {
            // Step 1: 消费消息 - 反序列化 JSON 消息为 LogDto 对象
            LogDto logDto = objectMapper.readValue(message, LogDto.class);

            // Step 2: 将 LogDto 转换为 Map 格式,方便 Elasticsearch 存储
            Map<String, Object> logMap = BeanUtil.beanToMap(logDto);

            // Step 3: 构造 Elasticsearch 索引请求
            IndexRequest indexRequest = new IndexRequest(logDto.getIndex())
                    .id(logDto.getTraceId())  // 可选:使用唯一的 Trace ID 作为文档 ID
                    .source(logMap);  // 设置日志数据作为文档内容

            // Step 4: 存储日志到 Elasticsearch
            IndexResponse response = restHighLevelClient.index(indexRequest, RequestOptions.DEFAULT);

            // 判断 Elasticsearch 操作结果
            if (response.getResult() == DocWriteResponse.Result.CREATED) {
                // 日志创建成功
                System.out.println("Log successfully stored in Elasticsearch.");
            } else {
                // 日志更新成功(如果存在相同 ID 的日志)
                System.out.println("Log updated in Elasticsearch.");
            }
        } catch (Exception e) {
            // 错误处理,记录日志失败
            System.err.println("Error processing log message: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

附上LogDto代码

@Data
@Builder
@AllArgsConstructor
@ApiModel(value="日志", description="日志")
public class LogDto implements Serializable {

    private static final long serialVersionUID=1L;

    @ApiModelProperty(value = "业务ID")
    private Long businessId;

    @ApiModelProperty(value = "存放目录")
    private String index;

    @ApiModelProperty(value = "操作人")
    private Long userId;

    @ApiModelProperty(value = "类型描述(比如:改约)")
    private String operationTypeName;

    @ApiModelProperty(value = "操作人姓名")
    private String nickName;

    @ApiModelProperty(value = "备注")
    private String remark;

    @ApiModelProperty(value = "路径")
    private String loginPath;

    @ApiModelProperty(value = "唯一ID")
    private String traceId;

    @ApiModelProperty(value = "IP")
    private String ip;

    @ApiModelProperty(value = "操作人名称")
    private String userName;

    @ApiModelProperty(value = "操作人手机号")
    private String userMobile;

    @ApiModelProperty(value = "操作人工号")
    private String workerNo;

    @ApiModelProperty(value = "日志来源: 1.用户端,2.管理端,3.企微扩展")
    private Integer source;

    @ApiModelProperty(value = "创建时间")
    private Date gmtCreate;

    @ApiModelProperty(value = "分页开始行数")
    private Integer from;

    @ApiModelProperty(value = "返回条数")
    private Integer size;

    public LogDto() {
    }

    public LogDto(String index, Long businessId, Long userId, String nickName, String remark,String ip) {
        this.index = index;
        this.businessId = businessId;
        this.userId = userId;
        this.nickName = nickName;
        this.remark = remark;
        this.ip = ip;
    }
}

Tips:如果是记录修改前修改后的状态,直接修改传参remark即可 比如

remark = "修改前:"+"123"+" 修改后:"+"321";

2. Elasticsearch 日志查询

前端日志查询接口 listLogById,通过 LogDto 传递查询条件,并调用 Elasticsearch 执行查询:

@PostMapping("/listLogById")
public ResultBody<List<LogVo>> listLogById(@RequestParam String index, @RequestParam Long id) {
    if ("".equals(index)) {
        return ResultBody.failed().code(1000).msg("索引不能为空");
    }
    LogDto logDto = new LogDto();
    logDto.setIndex(index);
    logDto.setBusinessId(id);
    Page<LogVo> logDtos = null;
    try {
        logDtos = esService.listLog(logDto);
    } catch (Exception e) {
        if (e.getMessage() != null && "index_not_found_exception".contains(e.getMessage())) {
            return ResultBody.failed();
        }
        return ResultBody.failed().code(1000).msg(e.getMessage());
    }
    return ResultBody.ok().data(logDtos);
}

Elasticsearch 具体查询逻辑

esService.listLog 方法中,查询逻辑通过 Elasticsearch 客户端实现:

public Page<LogVo> exec(LogDto logDto) {
    ClientInterface clientUtil = ElasticSearchHelper.getConfigRestClientUtil("esmapper/ESTracesMapper.xml");
    Map<String, Object> objectMap = BeanUtil.beanToMap(logDto);
    Map params = new HashMap();
    objectMap.entrySet().removeIf(entry -> entry.getValue() == null);
    params.put("from", objectMap.get("from"));
    params.put("size", objectMap.get("size"));
    objectMap.remove("from");
    objectMap.remove("size");
    params.put("objectMap", objectMap);
    params.put("mapsize", objectMap.size());

    ESDatas<LogVo> esDatas = clientUtil.searchList(
        logDto.getIndex() + "/_search",
        "searchLogPagineDatas",
        params,
        LogVo.class
    );

    List<LogVo> list = new ArrayList<>();
    if (esDatas.getDatas() != null) {
        list = esDatas.getDatas();
        if (list != null) {
            list = list.stream().collect(Collectors.collectingAndThen(
                Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(o -> o.getBusinessId() + ";" + o.getRemark() + ";" + o.getGmtCreate()))),
                ArrayList::new
            ));
            list = list.stream().sorted(Comparator.comparing(LogVo::getGmtCreate).reversed()).collect(Collectors.toList());
        }
    }

    long totalSize = esDatas.getTotalSize();
    Page<LogVo> page = new Page<>();
    page.setRecords(list);
    page.setTotal(totalSize);
    page.setSize(totalSize);
    page.setCurrent(0);
    return page;
}

主要代码解析:

ElasticSearchHelper.getConfigRestClientUtil("esmapper/ESTracesMapper.xml")

这行代码通过 ElasticSearchHelper 获取配置的 ClientInterface 实例。ClientInterface 是一个封装了与 Elasticsearch 通信的方法的接口,getConfigRestClientUtil 是读取配置信息并初始化该接口的一个工厂方法。

  • "esmapper/ESTracesMapper.xml":这是 Elasticsearch 配置文件的路径,里面通常定义了查询语句模板,用于构建 Elasticsearch 查询。

clientUtil.searchList(...)

这行代码是通过 ClientInterface 发起一个查询请求。clientUtil 调用 searchList 方法来执行查询操作:

  • logDto.getIndex() + "/_search":这部分指定了 Elasticsearch 中的索引,logDto.getIndex() 返回了查询日志的目标索引。
  • "searchLogPagineDatas":这是查询的模板名称,通常是在配置文件(例如 esmapper/ESTracesMapper.xml)中定义的。这个查询模板描述了如何从 Elasticsearch 中获取日志数据。

核心特点:

  • 条件过滤:通过传入 LogDto 的条件,动态生成 Elasticsearch 查询参数。
  • 分页支持:利用 fromsize 实现分页查询。
  • 数据去重:使用 TreeSet 和自定义比较器对日志进行去重,确保数据唯一性。

总结

基于 RabbitMQ 和 Elasticsearch 的操作日志解决方案,不仅满足了高效存储和查询的需求,还通过异步处理和灵活的查询功能,极大地提升了系统性能和用户体验。这种方案适用于需要存储和查询海量日志的应用场景,平时小的项目个人建议直接用日志表即可。