暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

通过捕获 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 定义通用接口规则

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

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

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


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


      4.3.3 数据查询示例

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

        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

          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进阶助手


          抢先一步学习进阶干货

          文章转载自铭毅天下Elasticsearch,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

          评论