背景与需求
在企业应用中,操作日志是不可或缺的一部分,用于记录用户和系统的行为操作,包括新增、修改、删除等操作。这些日志不仅是审计和合规的需求,还能为问题追踪和行为分析提供关键数据。首先确定一点,需要什么:
- 需要记录大量的操作日志,同时支持快速查询。
- 需要按业务ID、操作类型、时间范围等条件检索日志。
- 支持海量日志数据的分页查询,并按时间或其他字段排序。
解决方案概述
重新建立日志表和ES对比
优缺点 | Elasticsearch | 传统关系型数据库表 |
---|---|---|
查询效率 | 高效的全文搜索和聚合分析,支持大规模日志的快速查询 | 查询性能受限于索引和表设计,大数据量时可能较慢 |
扩展性 | 高扩展性,支持分布式部署和水平扩展 | 水平扩展难度大,需要分库分表或复杂的分片设计 |
实时性 | 支持近实时的数据写入与查询 | 一般存在一定的延迟,尤其在大量数据写入时 |
易用性 | 配置和维护较为复杂,需要精心设计索引和查询结构 | 比较简单,但在数据量大时可能需要优化查询 |
存储要求 | 对内存和存储要求较高,需要较多资源支持 | 存储要求相对较低,适合存储结构化数据 |
数据一致性 | 最终一致性模型,不保证强一致性 | 强一致性,适合需要事务和严格数据一致性的场景 |
考虑到有多个服务都需要用到日志写入,与此同时公司正好CRM
也是使用ElasticSearch
,那决定就是你了。
本方案基于 RabbitMQ 和 Elasticsearch (ES) ,采用消息队列异步处理日志写入操作,借助 Elasticsearch 提供高效的日志存储和搜索功能,实现了以下核心功能:
- 异步记录日志:通过 RabbitMQ 实现异步日志写入,提升系统性能,避免对主业务流程的阻塞。
- 高效搜索与过滤:借助 Elasticsearch 的强大搜索能力,实现复杂条件查询。
- 日志去重与排序:在日志查询结果中,通过自定义逻辑实现去重与排序。
核心实现
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 查询参数。 - 分页支持:利用
from
和size
实现分页查询。 - 数据去重:使用
TreeSet
和自定义比较器对日志进行去重,确保数据唯一性。
总结
基于 RabbitMQ 和 Elasticsearch 的操作日志解决方案,不仅满足了高效存储和查询的需求,还通过异步处理和灵活的查询功能,极大地提升了系统性能和用户体验。这种方案适用于需要存储和查询海量日志的应用场景,平时小的项目个人建议直接用日志表即可。