搜狐技术产品

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

一、问题来源

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

  • 记录用户的业务操作行为:包括操作人、操作时间、操作功能、日志类型、操作内容描述、操作前后的数据报文等。
  • 提供可视化的查询页面:方便查询用户的操作记录,对重要操作进行回溯。
  • 支持误操作回滚:在必要时,对用户的误操作进行回滚处理。

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

二、问题描述

2.1 日志的类型

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

  • 系统日志:
    • 记录程序执行过程中的关键步骤,用于输出 debug、info、warn、error 等不同级别的信息。
    • 这类日志主要供程序员和运维人员查看,帮助快速排查故障。
  • 操作日志:
    • 记录用户的实际业务操作行为,如哪个用户在什么时间点击了某个菜单,修改了哪个配置等。
    • 这类日志一般存储在数据库中,供普通用户或系统管理员查看。

2.2 传统实现方式的局限

2.2.1 业务代码嵌套日志

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

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

2.2.2 AOP(面向切面编程)

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

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

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

@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 数据库的变更,包括表结构和数据的变化。

Image

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

3.2 为什么选择 Canal?

  • 解耦业务代码:
    • 不需要修改现有的业务代码,降低了系统的耦合度。
  • 支持批量操作和多表关联:
    • 由于直接从数据库层面获取变更数据,能够方便地处理复杂的业务场景。
  • 不依赖开发语言:
    • Canal 与具体的编程语言无关,适用于各种技术栈的项目。

3.3 Canal 的优缺点

  • 优点
    • 解除了数据新旧变化的耦合。
    • 支持批量操作和多表关联拓展。
    • 不依赖于特定的开发语言。
  • 缺点
    • 数据库表设计需要统一的约定。
    • 对于多表级联保存和更新的数据,可能存在兼容性问题。
    • 需要处理非业务层面的数据变更(如手动修改数据库)。

四、方案实施

4.1 数据解析与转换

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

{
  "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 定义通用接口规则

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

Image

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

{
  "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 索引与映射

首先,定义索引和映射:

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"          }        }      }    }  }}

Image


4.3.2 数据插入示例

插入操作日志数据:

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"}

Image

4.3.3 数据查询示例

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

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

Image

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

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                      ]                    }                  }                ]              }            }          }        }      ]    }  }}

Image

五、多表关联问题处理

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

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

解决方案:

  • 使用事务 ID(txID):通过事务 ID,将同一事务内的操作聚合在一起,便于追踪和回滚。
  • 统一更新操作人:确保系统在进行数据更新时,正确记录操作人信息,方便后续的日志分析。

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

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

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

七、小结

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

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

  • 多表关联的处理:需要更复杂的逻辑来聚合和关联数据变更。
  • 操作人的准确性:需要业务系统配合,确保每次数据变更都能正确记录操作人。

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

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


作者:海鸥

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

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