原文地址: https://debezium.io/blog/2019/10/01/audit-logs-with-change-data-capture-and-stream-processing/
欢迎关注留言,我是收集整理小能手,工具翻译,仅供参考,笔芯笔芯.
使用变更数据捕获和流处理构建审核日志
2019 年 10 月 1 日 作者: Gunnar Morling
讨论 示例 apache-kafka kafka-streams
业务应用程序通常要求维护某种形式的审核日志,即应用程序数据的所有更改的持久跟踪。如果你仔细观察,会发现包含 Debezium 数据更改事件的 Kafka 主题与此非常相似:源自数据库事务日志,它描述了应用程序记录的所有更改。但缺少的是一些元数据:数据为何、何时以及由谁更改?在这篇文章中,我们将探讨如何通过变更数据捕获 (CDC) 提供和公开元数据,以及如何使用流处理通过此类元数据丰富实际数据变更事件。
维护数据审计跟踪的原因有很多:例如,监管要求可能要求企业保留其客户、采购订单、发票或其他数据的完整历史信息。此外,对于企业自身而言,深入了解某些数据发生变化的原因和方式也非常有用,例如允许改进业务流程或分析错误。
创建审计跟踪的一种常见方法是应用程序端库。连接到所选的持久性库,他们将维护数据表中的特定列(“createdBy”、“lastUpdated”等),和/或将早期记录版本复制到某种形式的历史表中。
不过,这样做也有一些缺点:
作为 OLTP 事务的一部分在历史表中写入记录会增加事务内执行的语句数量(对于每次更新或删除,还必须将插入写入相应的历史表中),因此可能会导致应用程序的响应时间更长
通常,在批量更新和删除的情况下无法提供审核事件(例如DELETE from purchaseorders where status = ‘SHIPPED’),因为用于将库挂接到持久性框架的侦听器不知道所有受影响的记录
无法跟踪直接在数据库中完成的更改,例如运行数据加载时、在存储过程中进行批处理或在紧急数据补丁期间绕过应用程序时
另一种技术是数据库触发器。它们不会错过任何操作,无论是从应用程序还是数据库本身发出的。他们还能够处理受批量语句影响的每条记录。不利的一面是,在 OLTP 事务中执行触发器时仍然存在延迟增加的问题。此外,还必须制定一个流程来安装和更新每个表的触发器。
基于变更数据捕获的审核日志
当利用事务日志作为审计跟踪的来源并使用更改数据捕获来检索更改信息并将其发送到消息代理或日志(例如 Apache Kafka)时,上述问题不存在。
CDC 进程异步运行,可以提取变更数据,而不会影响 OLTP 事务。每当有数据更改时,无论是从应用程序发出还是直接在数据库中执行,事务日志都会包含一个条目。在批量操作中更新或删除的每条记录都会有一个日志条目,因此可以为每条记录生成一个更改事件。而且对数据模型没有影响,即不必创建特殊列或历史表。
但是 CDC 如何访问我们最初讨论的元数据呢?例如,这可以是执行数据更改的应用程序用户、其 IP 地址和设备配置、跟踪范围 ID 或应用程序用例的标识符等数据。
由于该元数据通常不会(也不应该)存储在应用程序的实际业务表中,因此必须单独提供。一种方法是使用一个单独的表来存储此元数据。对于每个执行的事务,业务应用程序都会在该表中生成一条记录,其中包含所有必需的元数据并使用事务 ID 作为主键。当运行手动数据更改时,还可以轻松地为元数据记录提供额外的插入。由于 Debezium 的数据更改事件包含导致特定更改的事务的 id,因此数据更改事件和元数据记录可以关联起来。
在本文的其余部分中,我们将仔细研究业务应用程序如何提供事务范围的元数据,以及如何使用 Kafka Streams API 通过相应的元数据来丰富数据更改事件。
解决方案概述
下图显示了基于管理蔬菜数据的微服务示例的整体解决方案设计:
图片来自于官网原文
使用变更数据捕获和流处理进行审核
涉及到两个服务:
Vegetables-service:一个简单的 REST 服务,用于将蔬菜数据插入和更新到 Postgres 数据库中;作为其处理的一部分,它不仅会更新其实际的“业务表” vegetable,还会将一些审计元数据插入到专用的元数据表中transaction_context_data;Debezium 用于将两个表中的更改事件流式传输到 Apache Kafka 中的相应主题中
log-enricher:使用 Kafka Streams 和 Quarkus 构建的流处理应用程序,它使用主题dbserver1.inventory.vegetable中相应的元数据丰富来自包含蔬菜更改事件 ( )的 CDC 主题的消息dbserver1.inventory.transaction_context_data,并将丰富的蔬菜更改事件写回 Kafka 到dbserver1.inventory.vegetable.enriched主题中。
您可以在 GitHub 上找到包含所有组件以及运行它们的说明的完整示例。
提供审计元数据
我们首先讨论蔬菜服务等应用程序如何提供所需的审核元数据。例如,以下元数据应可用于审计目的:
sub进行数据更改的应用程序用户,由JWT 令牌(JSON Web 令牌)的声明表示
请求时间戳,由DateHTTP 标头表示
用例标识符,通过调用的 REST 资源方法上的自定义 Java 注释提供
以下是使用JAX-RS API持久保存新蔬菜的 REST 资源的基本实现:
@Path("/vegetables")
@RequestScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class VegetableResource {
@Inject
VegetableService vegetableService;
@POST
@RolesAllowed({"farmers"})
@Transactional
@Audited(useCase="CREATE VEGETABLE")
public Response createVegetable(Vegetable vegetable) {
if (vegetable.getId() != null) {
return Response.status(Status.BAD_REQUEST.getStatusCode()).build();
}
vegetable = vegetableService.createVegetable(vegetable);
return Response.ok(vegetable).status(Status.CREATED).build();
}
// update, delete ...
}
如果您以前曾经使用 JAX-RS 构建过 REST 服务,那么该实现对您来说将会很熟悉:带有 注释的资源方法获取@POST传入的请求负载并将其传递给通过 CDI 注入的服务 bean。@Audited不过,注释很特别。它是一种自定义注释类型,有两个用途:
指定应在审核日志中引用的用例(“CREATE VEGETABLE”)
绑定一个拦截器,每次调用带有注释的方法时都会触发该拦截器@Audited
每当调用带有注释的方法时,该拦截器就会启动@Audited,并实现用于写入事务范围审计元数据的逻辑。它看起来像这样:
@Interceptor
@Audited(useCase = “”)
@Priority(value = Interceptor.Priority.APPLICATION + 100)
public class TransactionInterceptor {
@Inject
JsonWebToken jwt;
@Inject
EntityManager entityManager;
@Inject
HttpServletRequest request;
@AroundInvoke
public Object manageTransaction(InvocationContext ctx) throws Exception {
BigInteger txtId = (BigInteger) entityManager
.createNativeQuery("SELECT txid_current()")
.getSingleResult();
String useCase = ctx.getMethod().getAnnotation(Audited.class).useCase();
TransactionContextData context = new TransactionContextData();
context.transactionId = txtId.longValueExact();
context.userName = jwt.claim("sub").orElse("anonymous");
context.clientDate = getRequestDate();
context.useCase = useCase;
entityManager.persist(context);
return ctx.proceed();
}
private ZonedDateTime getRequestDate() {
String requestDate = request.getHeader(HttpHeaders.DATE);
return requestDate != null ?
ZonedDateTime.parse(requestDate, DateTimeFormatter.RFC_1123_DATE_TIME) :
null;
}
}
@Interceptor并将@Audited其标记为绑定到我们的自定义@Audited注释的拦截器。
该@Priority注释控制应在拦截器堆栈中的哪个点调用审核拦截器。Priority.APPLICATION任何应用程序提供的拦截器都应具有大于(2000)的优先级;特别是,这确保了之前已经通过注释@Transactional及其在Priority.PLATFORM_BEFORE范围(< 1000)内运行的附带拦截器启动了事务。
通过MicroProfile JWT RBAC API注入调用者的 JWT 令牌
对于每个审核的方法,拦截器都会触发并会
获取当前事务 ID(执行此操作的确切方法是特定于数据库的,在示例中txid_current()调用来自 Postgres 的函数)
TransactionContextData通过 JPA持久保存一个实体;它的主键值是之前选择的事务ID,它具有用户名(从JWT令牌中获取)、请求日期(从HTTPDATE请求头中获取)和用例标识符(从JWT@Audited的注释中获取)等属性。调用的方法)
继续被调用方法的调用流程
当调用 REST 服务来创建和更新一些蔬菜时,应在数据库中创建以下记录(请参阅提供的示例中的自述文件,了解有关构建示例代码并使用合适的 JWT 令牌调用蔬菜服务的说明):
vegetablesdb> select * from inventory.vegetable;
±-----±--------------±--------+
| id | description | name |
|------±--------------±--------|
| 1 | Spicy! | Potato |
| 11 | Delicious! | Pumpkin |
| 10 | Tasty! | Tomato |
±-----±--------------±--------+
vegetablesdb> select * from inventory.transaction_context_data;
±-----------------±--------------------±-----------------±---------------+
| transaction_id | client_date | usecase | user_name |
|------------------±--------------------±-----------------±---------------|
| 608 | 2019-08-22 08:12:31 | CREATE VEGETABLE | farmerbob |
| 609 | 2019-08-22 08:12:31 | CREATE VEGETABLE | farmerbob |
| 610 | 2019-08-22 08:12:31 | UPDATE VEGETABLE | farmermargaret |
±-----------------±--------------------±-----------------±---------------+
通过审核元数据丰富变更事件
将业务数据(蔬菜)和事务范围的元数据存储在数据库中后,就可以设置Debezium Postgres 连接器并将数据更改从vegetable和transaction_context_data表传输到相应的 Kafka 主题。再次参阅示例自述文件以了解部署连接器的详细信息。
该dbserver1.inventory.vegetable主题应包含创建、更新和删除的蔬菜记录的更改事件,而dbserver1.inventory.transaction_context_data主题应仅包含每个插入的元数据记录的创建消息。
主题保留
为了管理所涉及主题的增长,应该明确定义每个主题的保留策略。例如,对于包含丰富更改事件的实际审核日志主题,基于时间的保留策略可能适合,根据您的要求将每个日志事件保留尽可能长的时间。另一方面,事务元数据主题的生命周期可能相当短暂,因为一旦处理了所有相应的数据更改事件,就不再需要其条目。设置一些端到端延迟监控可能是一个好主意,以确保日志丰富器流应用程序跟上传入消息,并且不会落后那么远,从而面临事务风险在处理相应的更改事件之前消息被丢弃。
现在,如果我们查看两个主题的消息,我们可以看到它们可以根据事务 ID 进行关联。它是source蔬菜变化事件结构的一部分,是交易元数据事件的消息键:
图片来自于官网原文
蔬菜和交易元数据消息
一旦我们找到给定蔬菜变化事件的相应交易事件,就可以将前者的client_date,usecase和user_name属性添加到后者:
图片来自于官网原文
丰富的蔬菜讯息
这种消息转换是Kafka Streams 的完美用例,Kafka Streams是一种 Java API,用于在 Kafka 主题之上实现流处理应用程序,提供可让您过滤、转换、聚合和连接 Kafka 消息的运算符。
作为我们的流处理应用程序的运行时环境,我们将使用Quarkus,它是“专为 GraalVM 和 OpenJDK HotSpot 定制的 Kubernetes Native Java 堆栈,由最好的 Java 库和标准制作而成”。
使用 Quarkus 构建 Kafka Streams 应用程序
其中,Quarkus 附带了Kafka Streams 的扩展,它允许构建在 JVM 上运行的流处理应用程序,并作为提前编译的本机代码。它负责处理流式拓扑的生命周期,因此您不必处理诸如注册 JVM 关闭挂钩、等待所有输入主题的创建等细节。
该扩展还提供“实时开发”支持,可以在您处理流处理应用程序时自动重新加载它,从而在开发过程中实现非常快的周转周期。
连接逻辑
在考虑丰富逻辑的实际实现时,流到流连接可能是一个合适的解决方案。通过为两个主题创建KStreams,我们可以尝试实现连接功能。但一个挑战是如何定义合适的加入窗口,因为两个主题的消息之间没有时间保证,而且我们不能错过任何事件。
另一个问题出现在变更事件的排序保证方面。默认情况下,Debezium 将使用表的主键作为相应 Kafka 消息的消息键。这意味着同一蔬菜记录的所有消息都将具有相同的键,因此将进入蔬菜 Kafka 主题的同一分区。这反过来又保证了这些事件的使用者能够以与创建时完全相同的顺序看到与同一蔬菜记录相关的所有消息。
现在,为了加入两个流,双方的消息密钥必须相同。这意味着蔬菜主题必须通过事务 ID 重新键入(我们无法重新键入事务元数据主题,因为元数据事件中没有包含有关蔬菜的信息;即使是这种情况,一个事务可能会影响多个事务)蔬菜记录)。但这样做的话,我们就会失去原来的订购保证。一个蔬菜记录可能在两个后续事务中被修改,并且其更改事件可能最终出现在重新键入主题的不同分区中,这可能导致消费者在第一个更改事件之前接收到第二个更改事件。
如果KStream-KStream连接不可行,还能做什么?a和 之间的连接看起来也很有希望。它没有流到流连接的共同分区要求,因为所有分区都存在于分布式 Kafka Streams 应用程序的所有节点上。这似乎是一个可以接受的权衡,因为来自事务元数据主题的消息可以很快被丢弃,并且相应表的大小应该在合理的范围内。因此,我们可以有一个源自蔬菜的主题和一个基于交易元数据的主题。KStreamGlobalKTableGlobalKTableKStreamGlobalKTable
但不幸的是,存在一个时间问题:由于消息是从多个主题消费的,因此可能会发生在处理蔬菜流中的元素时,相应的事务元数据消息尚不可用的情况。因此,根据我们是使用内部联接还是左联接,在这种情况下,我们要么跳过更改事件,要么传播它们,而无需使用事务元数据丰富它们。这两种结果都不可取。
带缓冲的定制连接
KStream和的组合GlobalKTable仍然暗示着正确的方向。只是我们必须实现自定义的连接逻辑,而不是依赖内置的连接运算符。基本思想是缓冲到达蔬菜的消息KStream,直到从 s 状态存储中可以获得相应的事务元数据消息GlobalKTable。这可以通过创建一个自定义转换器来实现,该转换器实现所需的缓冲逻辑并应用于蔬菜KStream。
让我们从流拓扑本身开始。借助 Quarkus Kafka Streams 扩展,Topology只需返回对象的 CDI 生产者方法即可:
@ApplicationScoped
public class TopologyProducer {
static final String STREAM_BUFFER_NAME = "stream-buffer-state-store";
static final String STORE_NAME = "transaction-meta-data";
@ConfigProperty(name = "audit.context.data.topic")
String txContextDataTopic;
@ConfigProperty(name = "audit.vegetables.topic")
String vegetablesTopic;
@ConfigProperty(name = "audit.vegetables.enriched.topic")
String vegetablesEnrichedTopic;
@Produces
public Topology buildTopology() {
StreamsBuilder builder = new StreamsBuilder();
StoreBuilder> streamBufferStateStore =
Stores
.keyValueStoreBuilder(
Stores.persistentKeyValueStore(STREAM_BUFFER_NAME),
new Serdes.LongSerde(),
new JsonObjectSerde()
)
.withCachingDisabled();
builder.addStateStore(streamBufferStateStore);
builder.globalTable(txContextDataTopic, Materialized.as(STORE_NAME));
builder.stream(vegetablesTopic)
.filter((id, changeEvent) -> changeEvent != null)
.filter((id, changeEvent) -> !changeEvent.getString("op").equals("r"))
.transform(() -> new ChangeEventEnricher(), STREAM_BUFFER_NAME)
.to(vegetablesEnrichedTopic);
return builder.build();
}
}
状态存储将用作尚未处理的更改事件的缓冲区
GlobalKTable基于交易元数据主题
KStream基于蔬菜主题;在此流上,任何传入的逻辑删除标记都会被过滤,原因是审计跟踪主题的保留策略通常应该基于时间而不是基于日志压缩;
同样,快照事件也会被过滤,假设它们与审计跟踪无关,并且应用程序不会为 Debezium 连接器发起的快照事务提供任何相应的元数据
任何其他消息都通过自定义Transformer(见下文)使用相应的事务元数据进行丰富,并最终写入输出主题
主题名称是使用MicroProfile Config API注入的,值在 Quarkus application.properties配置文件中提供。除了主题名称之外,该文件还包含有关 Kafka 引导服务器的信息,默认的 serdes 信息:
audit.context.data.topic=dbserver1.inventory.transaction_context_data
audit.vegetables.topic=dbserver1.inventory.vegetable
audit.vegetables.enriched.topic=dbserver1.inventory.vegetable.enriched
quarkus.kafka-streams.bootstrap-servers=localhost:9092
quarkus.kafka-streams.application-id=auditlog-enricher
quarkus.kafka-streams.topics= a u d i t . c o n t e x t . d a t a . t o p i c , {audit.context.data.topic}, audit.context.data.topic,{audit.vegetables.topic}
kafka-streams.cache.max.bytes.buffering=10240
kafka-streams.commit.interval.ms=1000
kafka-streams.metadata.max.age.ms=500
kafka-streams.auto.offset.reset=earliest
kafka-streams.metrics.recording.level=DEBUG
kafka-streams.default.key.serde=io.debezium.demos.auditing.enricher.JsonObjectSerde
kafka-streams.default.value.serde=io.debezium.demos.auditing.enricher.JsonObjectSerde
kafka-streams.processing.guarantee=exactly_once
在下一步中,我们来看看ChangeEventEnricher我们的自定义转换器类。该实现基于更改事件被序列化为 JSON 的假设,但当然使用其他格式(例如 Avro 或 Protocol Buffers)也可以同样好地完成。
这是一些代码,但希望将其分解为多个较小的方法使其易于理解:
class ChangeEventEnricher implements Transformer
private static final Long BUFFER_OFFSETS_KEY = -1L;
private static final Logger LOG = LoggerFactory.getLogger(ChangeEventEnricher.class);
private ProcessorContext context;
private KeyValueStore txMetaDataStore;
private KeyValueStore streamBuffer;
@Override
@SuppressWarnings("unchecked")
public void init(ProcessorContext context) {
this.context = context;
streamBuffer = (KeyValueStore) context.getStateStore(
TopologyProducer.STREAM_BUFFER_NAME
);
txMetaDataStore = (KeyValueStore) context.getStateStore(
TopologyProducer.STORE_NAME
);
context.schedule(
Duration.ofSeconds(1),
PunctuationType.WALL_CLOCK_TIME, ts -> enrichAndEmitBufferedEvents()
);
}
@Override
public KeyValue transform(JsonObject key, JsonObject value) {
boolean enrichedAllBufferedEvents = enrichAndEmitBufferedEvents();
if (!enrichedAllBufferedEvents) {
bufferChangeEvent(key, value);
return null;
}
KeyValue enriched = enrichWithTxMetaData(key, value);
if (enriched == null) {
bufferChangeEvent(key, value);
}
return enriched;
}
/**
* Enriches the buffered change event(s) with the metadata from the associated
* transactions and forwards them.
*
* @return {@code true}, if all buffered events were enriched and forwarded,
* {@code false} otherwise.
*/
private boolean enrichAndEmitBufferedEvents() {
Optional seq = bufferOffsets();
if (!seq.isPresent()) {
return true;
}
BufferOffsets sequence = seq.get();
boolean enrichedAllBuffered = true;
for(long i = sequence.getFirstValue(); i < sequence.getNextValue(); i++) {
JsonObject buffered = streamBuffer.get(i);
LOG.info("Processing buffered change event for key {}",
buffered.getJsonObject("key"));
KeyValue enriched = enrichWithTxMetaData(
buffered.getJsonObject("key"), buffered.getJsonObject("changeEvent"));
if (enriched == null) {
enrichedAllBuffered = false;
break;
}
context.forward(enriched.key, enriched.value);
streamBuffer.delete(i);
sequence.incrementFirstValue();
}
if (sequence.isModified()) {
streamBuffer.put(BUFFER_OFFSETS_KEY, sequence.toJson());
}
return enrichedAllBuffered;
}
/**
* Adds the given change event to the stream-side buffer.
*/
private void bufferChangeEvent(JsonObject key, JsonObject changeEvent) {
LOG.info("Buffering change event for key {}", key);
BufferOffsets sequence = bufferOffsets().orElseGet(BufferOffsets::initial);
JsonObject wrapper = Json.createObjectBuilder()
.add("key", key)
.add("changeEvent", changeEvent)
.build();
streamBuffer.putAll(Arrays.asList(
KeyValue.pair(sequence.getNextValueAndIncrement(), wrapper),
KeyValue.pair(BUFFER_OFFSETS_KEY, sequence.toJson())
));
}
/**
* Enriches the given change event with the metadata from the associated
* transaction.
*
* @return The enriched change event or {@code null} if no metadata for the
* associated transaction was found.
*/
private KeyValue enrichWithTxMetaData(JsonObject key,
JsonObject changeEvent) {
JsonObject txId = Json.createObjectBuilder()
.add("transaction_id", changeEvent.get("source").asJsonObject()
.getJsonNumber("txId").longValue())
.build();
JsonObject metaData = txMetaDataStore.get(txId);
if (metaData != null) {
LOG.info("Enriched change event for key {}", key);
metaData = Json.createObjectBuilder(metaData.get("after").asJsonObject())
.remove("transaction_id")
.build();
return KeyValue.pair(
key,
Json.createObjectBuilder(changeEvent)
.add("audit", metaData)
.build()
);
}
LOG.warn("No metadata found for transaction {}", txId);
return null;
}
private Optional bufferOffsets() {
JsonObject bufferOffsets = streamBuffer.get(BUFFER_OFFSETS_KEY);
if (bufferOffsets == null) {
return Optional.empty();
}
else {
return Optional.of(BufferOffsets.fromJson(bufferOffsets));
}
}
@Override
public void close() {
}
}
当蔬菜变更事件到来时,GlobalKTable以变更事件所在区块的交易idsource为键,在交易主题的状态存储中查找相应的元数据;如果可以找到元数据,则将元数据添加到更改事件(在该audit字段下)并返回该丰富事件
如果找不到元数据,则将传入事件添加到更改事件缓冲区中并返回
在实际到达传入事件之前,所有缓冲的事件都会被处理;这是为了确保保留原始更改事件所必需的;只有当所有内容都可以丰富时,传入的事件也会被处理
为了在没有新的更改事件传入时发出缓冲事件,会安排一个标点符号来定期处理缓冲区
相应元数据尚未到达的蔬菜事件的缓冲区
关键部分是不可处理的更改事件的缓冲区。为了维持事件的顺序,必须按照插入顺序处理缓冲区,从首先插入的事件开始(想象一下 FIFO 队列)。由于从 a 获取所有条目时无法保证遍历顺序KeyValueStore,因此这是通过使用严格递增序列的值作为键来实现的。键值存储中的特殊条目用于存储有关缓冲区中当前“最旧”索引和下一个序列值的信息。
人们还可以考虑这种缓冲区的替代实现,例如基于 Kafka 主题或KeyValueStore确保从最旧条目到最新条目的迭代顺序的自定义实现。最终,如果 Kafka Streams 具有重试尚无法加入的流元素的内置方法,那么它也可能很有用;这将避免任何自定义缓冲实现。
如果出了问题
对于可靠且一致的处理逻辑,考虑失败时的行为至关重要,例如,如果流应用程序在将元素添加到缓冲区之后但在更新序列值之前崩溃。
关键是application.properties中给出的属性exactly_once值。这确保了事务处理的一致性;例如,在上述场景中,重新启动后,将再次处理原始更改事件,并且缓冲区状态将看起来与第一次处理事件之前完全相同。processing.guarantee
浓缩蔬菜活动的消费者应采用以下隔离级别read_committed:否则,在转发缓冲事件之后但在从缓冲区中删除事件之前,如果应用程序崩溃,他们可能会看到未提交的消息,从而导致重复的消息。
自定义转换器逻辑到位后,我们可以构建 Quarkus 项目并运行流处理应用程序。您应该在dbserver1.inventory.vegetable.enriched主题中看到类似这样的消息:
{“id”:10}
{
“before”: {
“id”: 10,
“description”: “Yummy!”,
“name”: “Tomato”
},
“after”: {
“id”: 10,
“description”: “Tasty!”,
“name”: “Tomato”
},
“source”: {
“version”: “0.10.0-SNAPSHOT”,
“connector”: “postgresql”,
“name”: “dbserver1”,
“ts_ms”: 1569700445392,
“snapshot”: “false”,
“db”: “vegetablesdb”,
“schema”: “inventory”,
“table”: “vegetable”,
“txId”: 610,
“lsn”: 34204240,
“xmin”: null
},
“op”: “u”,
“ts_ms”: 1569700445537,
“audit”: {
“client_date”: 1566461551000000,
“usecase”: “UPDATE VEGETABLE”,
“user_name”: “farmermargaret”
}
}
当然,缓冲区处理逻辑可以根据您的具体需求进行调整;例如,我们也可能认为,在等待一段时间后传播未丰富的更改事件或引发指示丢失元数据的异常更有意义,而不是无限期地等待相应的事务元数据。
为了查看缓冲是否按预期工作,您可以做一个小实验:直接在数据库中使用 SQL 修改蔬菜记录。Debezium 将捕获该事件,但由于没有提供相应的交易元数据,该事件将不会转发到丰富的蔬菜主题。如果您使用 REST API 添加另一种蔬菜,该蔬菜也不会被传播:尽管有它的元数据记录,但它会被其他更改事件阻止。仅当您将第一个更改事务的元数据记录插入到transaction_context_data表中后,两个更改事件才会被处理并发送到输出主题。
概括
在这篇博文中,我们讨论了如何将变更数据捕获与流处理结合起来以高效、低开销的方式构建审核日志。与基于库和基于触发器的方法相比,形成审计跟踪的事件是通过 CDC 从数据库的事务日志中检索的,除了每个事务插入单个元数据记录(任何类型都需要以类似的形式)审计日志),不会产生 OLTP 事务开销。当数据记录进行批量更新或删除时,还可以获取审核日志条目,而基于库的审核解决方案通常无法做到这一点。
应用程序可以通过单独的表提供通常应作为审核日志一部分的其他元数据,该表也通过 Debezium 捕获。在 Kafka Streams 的帮助下,可以使用该元数据表中的数据来丰富实际的数据更改事件。
我们尚未讨论的一方面是查询审计跟踪条目,例如检查数据的特定早期版本。为此,丰富的变更数据事件通常将存储在可查询的数据库中。与基本数据复制管道不同,在这种情况下,不仅每条记录的最新版本将存储在数据库中,而且所有版本(即主键)通常将用每次更改的事务ID进行修改。这将允许选择单个数据记录甚至多个表的联接,以使数据根据给定的事务 ID 有效。如何详细实施这一点可能会在以后的帖子中讨论。
非常欢迎您对这种构建审核日志的方法提供反馈,只需在下面发表评论即可。要开始自己的实现,您可以查看GitHub 上 Debezium 示例存储库中的代码。
非常感谢Chris Cranford、Hans-Peter Grahsl、Ashhar Hasan、Anna McDonald和 Jiri Pechanec 在撰写本文和随附的示例代码时提供的反馈!