基于 Canal + Elasticsearch 的业务操作日志解决方案

一、问题来源

在日常的业务系统中,操作日志 是不可或缺的一部分。它能帮助我们追踪用户的操作行为,记录关键数据的变更,甚至在必要时支持操作回滚。最近,我们接到客户的需求,希望在系统中实现一个业务操作日志管理的功能,具体包括:

  • 记录用户的业务操作行为:包括操作人、操作时间、操作功能、日志类型、操作内容描述、操作前后的数据报文等。

  • 提供可视化的查询页面:方便查询用户的操作记录,对重要操作进行回溯。

  • 支持误操作回滚:在必要时,对用户的误操作进行回滚处理。

这个需求看似简单,但要在不影响现有业务逻辑的情况下,实现高效、通用的操作日志记录,确实需要好好思考一番。

二、问题描述

2.1 日志的类型

在业务系统中,常见的日志类型主要有两种:

  • 系统日志

    • 记录程序执行过程中的关键步骤,用于输出 debuginfowarnerror 等不同级别的信息。

    • 这类日志主要供程序员和运维人员查看,帮助快速排查故障。

  • 操作日志

    • 记录用户的实际业务操作行为,如哪个用户在什么时间点击了某个菜单,修改了哪个配置等。

    • 这类日志一般存储在数据库中,供普通用户或系统管理员查看。

2.2 传统实现方式的局限

2.2.1 业务代码嵌套日志

最直接的方法是在业务代码中手动添加日志记录。

例如,在每个数据库操作的前后,记录操作名称、时间、影响的数据等信息。然而,这种方式需要修改大量的业务代码,增加了编码的复杂度,而且不够通用。

2.2.2 AOP(面向切面编程)

AOP 是一种编程范式,能够将日志记录等通用功能与业务逻辑分离。

在 Spring 框架中,常用 AOP 来实现操作日志的记录。然而,AOP 在处理数据变更前后的值、批量操作、多表关联等复杂场景时,显得力不从心。

举个例子,我之前尝试过一种方案,通过在数据对象中设置 newDataoldData 两个属性来记录数据的前后变化:

go 复制代码
@Valid
@NotNull(message = "新值不能为空")
@UpdateNewDataOperationLog
private T newData;

@Valid
@NotNull(message = "旧值不能为空")
@UpdateOldDataOperationLog
private T oldData;

这种方式存在以下问题:

  1. 旧值的获取问题 :如果不再次查询数据库,就需要前端将旧值封装到 oldData 对象中,但这可能导致数据不一致。

  2. 无法处理批量数据 :对于 List 类型的数据,处理起来相当麻烦。

  3. 不支持多表操作:当一个业务操作涉及多个表时,很难完整地记录操作日志。

三、方案探讨

面对上述问题,我们需要一种更高效、更通用的解决方案。经过调研,我们发现了 Canal 这款神器(咱们之前文章也提及和验证过这个方案)。

3.1 Canal 的技术原理

Canal 是阿里巴巴开源的一款基于 MySQL 二进制日志(Binlog)的增量数据订阅和消费组件。它的主要功能是实时监听 MySQL 数据库的变更,包括表结构和数据的变化。

通过捕获 Binlog,Canal 能够获取数据库层面的原始变更事件(如 INSERTUPDATEDELETE),并将其解析为可消费的数据。

3.2 为什么选择 Canal?

  • 解耦业务代码

    • 不需要修改现有的业务代码,降低了系统的耦合度。
  • 支持批量操作和多表关联

    • 由于直接从数据库层面获取变更数据,能够方便地处理复杂的业务场景。
  • 不依赖开发语言

    • Canal 与具体的编程语言无关,适用于各种技术栈的项目。

3.3 Canal 的优缺点

  • 优点

    • 解除了数据新旧变化的耦合。

    • 支持批量操作和多表关联拓展。

    • 不依赖于特定的开发语言。

  • 缺点

    • 数据库表设计需要统一的约定。

    • 对于多表级联保存和更新的数据,可能存在兼容性问题。

    • 需要处理非业务层面的数据变更(如手动修改数据库)。

四、方案实施

4.1 数据解析与转换

首先,Canal 采集并解析业务库的 Binlog 日志,将其投递到 Kafka 中。解析后的数据包括操作类型(如删除、修改、新增)以及新旧值,格式大致如下:

go 复制代码
{
  "data": [
    {
      "id": "122158992930664499",
      "goodsName": "新商品名称",
      "update_time": "2020-08-26 13:45:46"
    }
  ],
  "old": [
    {
      "goodsName": "旧商品名称",
      "update_time": "2020-08-26 09:15:13"
    }
  ],
  "database": "db_business",
  "table": "goods",
  "type": "UPDATE",
  "ts": 1587879945698
}

4.2 定义通用接口规则

为了兼容不同业务的字段定义,我们设计了一个通用的接口规范,返回变更前后的数据和字段描述。

以商品修改为例,接口如下:

go 复制代码
{
  "id": "10001",
  "groupID": 1700,
  "system": "01",
  "newObject": {
    "goodsName": "商品名称001",
    "goodsCode": "商品编码001"
  },
  "oldObject": {
    "goodsName": "商品名称",
    "goodsCode": "商品编码"
  },
  "fieldsDescription": {
    "goodsID": "商品ID",
    "goodsName": "商品名称",
    "goodsCode": "商品编码"
  },
  "action": 2,
  "description": "修改商品信息",
  "operator": "user001",
  "databaseName": "db_business",
  "tableName": "goods",
  "module": "商品管理",
  "txID": "36aef98585db4e7a98f9694c8ef28b8c",
  "timestamp": 1587879945698
}

字段解释:

  • groupID:集团 ID

  • databaseName:数据库名称

  • tableName:表名称

  • oldObject:变更前的数据

  • newObject:变更后的数据

  • fieldsDescription:字段描述,方便前端展示

  • operator:操作人

  • module:业务模块

  • action:操作类型(0:新增,1:删除,2:修改)

  • description:操作描述

  • txID:事务 ID

通过这个接口,我们可以将变更的数据直观地展示出来,也可以使用 JSONDiff 等工具高亮显示差异。

小提示 :如果同一个事务操作了多个表,为了完整地串联相关表的变更并支持回滚,可以使用 txID 将 Binlog 进行聚合处理。

4.3 数据存储

由于业务字段的变更不确定,我们选择使用 NoSQL 数据库来存储这些操作日志。

这里,我们采用了 Elasticsearch,并按照月份对各个业务线的索引进行切割。

4.3.1 Elasticsearch 索引与映射

首先,定义索引和映射:

复制代码
javascript 复制代码
PUT goods-nested
{
  "mappings": {
    "properties": {
      "id": {
        "type": "integer"
      },
      "groupID": {
        "type": "integer"
      },
      "bizSource": {
        "type": "keyword"
      },
      "action": {
        "type": "integer"
      },
      "description": {
        "type": "keyword"
      },
      "operator": {
        "type": "keyword"
      },
      "databaseName": {
        "type": "keyword"
      },
      "tableName": {
        "type": "keyword"
      },
      "bizmodule": {
        "type": "keyword"
      },
      "txId": {
        "type": "keyword"
      },
      "newObject": {
        "type": "nested",
        "properties": {
          "goodsID": {
            "type": "integer"
          },
          "goodsName": {
            "type": "keyword"
          },
          "goodsCode": {
            "type": "keyword"
          }
        }
      },
      "oldObject": {
        "type": "nested",
        "properties": {
          "goodsID": {
            "type": "integer"
          },
          "goodsName": {
            "type": "keyword"
          },
          "goodsCode": {
            "type": "keyword"
          }
        }
      },
      "fieldsDescription": {
        "type": "nested",
        "properties": {
          "goodsID": {
            "type": "integer"
          },
          "goodsName": {
            "type": "keyword"
          },
          "goodsCode": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

4.3.2 数据插入示例

插入操作日志数据:

复制代码
javascript 复制代码
POST goods-nested/_bulk
{"index":{"_index":"goods-nested","_id":"10001"}}
{"id":"10001","groupID":1700,"bizSource":"Scm","newObject":{"goodsID":1001,"goodsName":"商品名称001","goodsCode":"商品编码001"},"oldObject":{"goodsID":1001,"goodsName":"商品名称","goodsCode":"商品编码"},"fieldsDescription":{"goodsName":"商品名称","goodsCode":"商品编码"},"action":2,"description":"修改集团品相","operator":"001","databaseName":"db_supply_chain_basic","tableName":"tbl_chain_distribution","bizmodule":"集团品相","txId":"36aef98585db4e7a98f9694c8ef28b8c"}
{"index":{"_index":"goods-nested","_id":"10002"}}
{"id":"10002","groupID":1700,"bizSource":"Scm","newObject":{"goodsID":1002,"goodsName":"商品名称002","goodsCode":"商品编码002"},"oldObject":{"goodsID":1002,"goodsName":"商品名称","goodsCode":"商品编码"},"fieldsDescription":{"goodsName":"商品名称","goodsCode":"商品编码"},"action":2,"description":"修改集团品相","operator":"001","databaseName":"db_supply_chain_basic","tableName":"tbl_chain_distribution","bizmodule":"集团品相","txId":"36aef98585db4e7a98f9694c8ef28b8c"}
{"index":{"_index":"goods-nested","_id":"10003"}}
{"id":"10003","groupID":1700,"bizSource":"Scm","newObject":{"goodsID":1003,"goodsName":"商品名称003","goodsCode":"商品编码003"},"oldObject":{"goodsID":1003,"goodsName":"商品名称","goodsCode":"商品编码"},"fieldsDescription":{"goodsName":"商品名称","goodsCode":"商品编码"},"action":2,"description":"修改集团品相","operator":"001","databaseName":"db_supply_chain_basic","tableName":"tbl_chain_distribution","bizmodule":"集团品相","txId":"36aef98585db4e7a98f9694c8ef28b8c"}


4.3.3 数据查询示例

根据商品 ID 查询操作日志:

复制代码
javascript 复制代码
GET goods-nested/_search
{
  "query": {
    "nested": {
      "path": "newObject",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "newObject.goodsID": "1001"
              }
            }
          ]
        }
      }
    }
  }
}

如下查询是在索引 goods-nested 中查找满足以下条件的文档:groupID 等于 "1700",并且其嵌套字段 newObject 中的 goodsName 是 "商品名称001" 或 "商品名称002",同时 goodsID 是 1001 或 1002:

复制代码
javascript 复制代码
GET /goods-nested/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "groupID": "1700"
          }
        },
        {
          "nested": {
            "path": "newObject",
            "query": {
              "bool": {
                "must": [
                  {
                    "terms": {
                      "newObject.goodsName": [
                        "商品名称001",
                        "商品名称002"
                      ]
                    }
                  },
                  {
                    "terms": {
                      "newObject.goodsID": [
                        1001,
                        1002
                      ]
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}

五、多表关联问题处理

在实际业务中,一个操作可能涉及多个表的级联保存和更新。

然而,Binlog 的数据是无序的,如果上游数据的操作不在同一个事务中,处理起来会有一定困难。

解决方案:

  • 使用事务 ID(txID):通过事务 ID,将同一事务内的操作聚合在一起,便于追踪和回滚。

  • 统一更新操作人:确保系统在进行数据更新时,正确记录操作人信息,方便后续的日志分析。

六、过滤非业务层面的数据变更

需要注意的是,Binlog 中包含的不仅仅是业务系统的操作,还可能包括数据库工单、跑批等产生的数据变更。

为了避免干扰,需要对 Binlog 进行过滤,只保留业务层面的操作日志。

七、小结

通过以上的方案设计和实践,我们成功地实现了对业务操作日志的高效、通用记录。使用 Canal 捕获数据库层面的数据变更,再结合 Elasticsearch 进行存储和查询,不仅解耦了业务逻辑,还满足了客户的需求。

当然,这个方案并非完美,仍存在一些挑战:

  • 多表关联的处理:需要更复杂的逻辑来聚合和关联数据变更。

  • 操作人的准确性:需要业务系统配合,确保每次数据变更都能正确记录操作人。

但在系统架构设计中,没有完美的方案。我们需要在实用性和完美性之间找到平衡,适应业务的需求,不断优化和迭代。

最后,技术的发展是一个不断演进的过程。我们需要拥抱变化,灵活运用各种工具和方法,为业务提供最合适的解决方案。


作者:海鸥

14 年开发经验,现任某互联网 SaaS 公司TL+架构师,目前专注于 ERP 供应链 、新零售业务 、企业架构、中台架构、领域驱动设计、技术领导力等领域。死磕 Elasticsearch 知识星球常驻技术专家。

对于高并发、高可用、高性能、大数据处理有过丰富项目实战经验,乐于技术沟通分享。


更短时间更快习得更多干货!

和全球2000+ Elastic 爱好者一起精进!

elastic6.cn------ElasticStack进阶助手

抢先一步学习进阶干货!

相关推荐
万万君1 小时前
Linux 更改Jenkins使用其他账户启动
linux·jenkins
赤叶丶秋枫1 小时前
Jenkins入门使用
运维·jenkins
开心最重要(*^▽^*)1 小时前
Metricbeat安装教程——Linux——Metricbeat监控ES集群
linux·elasticsearch
紫菜(Nori)1 小时前
Jenkins Api Token 访问问题
运维·jenkins
叫我DPT1 小时前
Elasticsearch 数据存储底层机制详解
elasticsearch·搜索引擎·全文检索
努力的布布1 小时前
Elasticsearch-索引的批量操作
大数据·elasticsearch·搜索引擎·全文检索
RodrickOMG1 小时前
【大数据】Hadoop三节点集群搭建
大数据·hadoop·分布式
智慧化智能化数字化方案1 小时前
工业金融政务数据分类分级体系建设解读
大数据·金融·数据分类分级·政务·政务数据分类·工业数据分类·金融数据分类分级
DashVector2 小时前
如何通过HTTP API插入或更新Doc
大数据·数据库·数据仓库·人工智能·http·数据库架构·向量检索
斑驳竹影2 小时前
ElasticSearch存储引擎
大数据·elasticsearch·搜索引擎