使用Kafka订阅Binlog之字段值获取防坑指南(阿里云DTS)

836 阅读5分钟

在《如果可以,我想并行消费Kafka拉取的数据库Binlog》这篇文章中,笔者介绍如何实现并行消费Binlog,具体实现就是让同一张表的Binlog放到同一个线程去消费,用局部顺序消费换取消费速度,避免消息堆积。但在某些场景下,使用这种方式还是出现了问题,出现在关联表更新数据同步的先后顺序上。

在此分享下我们的解决方案:新增分组概念,将关联表放到同一分组,将同一分组的Binlog分配到同一线程消费。限制一个表只能分配到一个分组下,如果没有为表配置分组,则该表就是一个独立的分组,分组名称就是表名称。


在一次调试过程中笔者发现,日记打印的Binlog显示某些字段更新之前和更新之后都有值,可是到消费的时候获取字段的值却是null,如下图所示。

在此之前消费都是正常的,突然获取不到Binlog修改的字段值,而此次代码只是加了一条日记打印。添加打印消费到的每条Binlog记录的详细信息之后,消费就不正常了,并且现象很奇怪,数值类型与日期类型的字段依然能正常获取到值,只是字符串类型的字段获取不到值了。

排查此问题的思路:从Kafka拉取到消息到实际消费,这期间都做了什么,导致获取字段值为null

使用kafka拉取Binlog需要使用Avro反序列化消息,消息反序列化后生成com.alibaba.dts.formats.avro.Record对象,该对象记录了Binlog的操作类型、操作的库和表、字段、字段修改之前的镜像值与修改之后的镜像值。

Record对象获取字段的镜像值类型为"com.alibaba.dts.formats.avro"包下对应的类型,这些类型都提供有getValue方法获取值,但不同类型getValue方法返回值类型不同。

  • com.alibaba.dts.formats.avro.IntegergetValue方法返回值类型为java.lang.String
  • com.alibaba.dts.formats.avro.FloatgetValue方法返回值类型为java.lang.Double
  • ...

为便于使用,我们封装了获取镜像值的步骤,不管数据库字段是什么类型,统一将镜像值解析为字符串,并提供getAsStringgetAsIntegergetAsFloat之类的API,不必再关心数据库该字段的值是什么类型,只需要关心我想要获取的值应该是什么类型。

例如,将字段的镜像值都解析为FieldValue实例,FieldValue类定义如下:

public class FieldValue {
    private String encoding;
    private byte[] bytes;
    public String getAsString() {
        return new String(bytes, encoding);
    }
    public Integer getAsInteger() {
        return Integer.parseInt(getAsString());
    }
    public Long getAsLong() {
        return Long.parseLong(getAsString());
    }
    public BigDecimal getAsBigDecimal() {
        return new BigDecimal(getAsString());
    }
     // ......
}

以将com.alibaba.dts.formats.avro.Integer类型的字段值解析为FieldValue对象为例,实现解析代码如下。

static class NumberStringAdapter implements DataAdapter {
    @Override
    public FieldValue getFieldValue(Object data) {
        FieldValue fieldValue = new FieldValue();
        if (null != data) {
            com.alibaba.dts.formats.avro.Integer integer = (com.alibaba.dts.formats.avro.Integer) data;
            // 调用getValue获取字符串数值,并将字符串转为字节数组
            fieldValue.setValue(integer.getValue().getBytes(US_ASCII));
        }
        fieldValue.setEncoding("ASCII");
        return fieldValue;
    }
}

为便于使用,我们还可以将字段、字段修改之前的镜像值、字段修改之后的镜像值解析为一个个FieldHolder实例,FieldHolder的定义如下。

public abstract class FieldHolder {
    protected Field field;
    // 当操作为插入时,此字段没有值
    protected FieldValue beforeImage;
    // 当操作为删除时,此操作没有值
    protected FieldValue afterImage;
    
    // 省略get方法
    
    public FieldHolder(Field field, Object beforeImage, Object afterImage) {
       //....
    }

    // 比较该字段的值是否修改了
    public boolean isModify() {
        if (beforeImage == null && afterImage == null) {
            return false;
        }
        if (beforeImage == null) {
            return true;
        }
        if (afterImage == null) {
            return true;
        }
        return !getBeforeFieldValue().equals(getAfterFieldValue());
    }
}

IntegerFloatcom.alibaba.dts.formats.avro包下的类)不同的是,CharacterBinaryObjectgetValue方法返回值类型为java.nio.ByteBuffer。将Character类型的字段值解析为FieldValue对象的实现代码如下。

static class CharacterAdapter implements DataAdapter {
    @Override
    public FieldValue getFieldValue(Object data) {
        FieldValue fieldValue = new FieldValue();
        if (null != data) {
            com.alibaba.dts.formats.avro.Character character = (com.alibaba.dts.formats.avro.Character) data;
            ByteBuffer buffer = character.getValue();
            byte[] ret = new byte[buffer.remaining()];
            buffer.get(ret);
            fieldValue.setValue(ret);
            fieldValue.setEncoding(character.getCharset());
        } else {
            fieldValue.setEncoding("ASCII");
        }
        return fieldValue;
    }
}

可以看出,产生此次bug的原因在于,调用ByteBufferget方法读取数据后,ByteBuffer的读偏移量(position)等于limit,由于没有调用flip重置读指针为0,导致后续再调用getFieldValue解析同一个镜像值时,解析后的FieldValue对象的bytes字段值是空的。

应将代码改为如下:

static class CharacterAdapter implements DataAdapter {
    @Override
    public FieldValue getFieldValue(Object data) {
        FieldValue fieldValue = new FieldValue();
        if (null != data) {
            com.alibaba.dts.formats.avro.Character character = (com.alibaba.dts.formats.avro.Character) data;
            ByteBuffer buffer = character.getValue();
            byte[] ret = new byte[buffer.remaining()];
            buffer.get(ret);
            fieldValue.setValue(ret);
            fieldValue.setEncoding(character.getCharset());
            character.getValue().flip();
        } else {
            fieldValue.setEncoding("ASCII");
        }
        return fieldValue;
    }
}

当然,我们可以让整个Record记录只解析一次,后续就不会出现反复读取字段值的情况。

定义Record解析器接口:

public interface RecordResolver<T extends FieldHolder> {
    String getDdl();
    String getDatabase();
    String getTable();
    Operation getOperation();
    FieldHolderMap<T> getFields();
}

根据Record对象创建Record解析器,在解析器构造方法中立即解析Record,外部每次调用Record解析器获取字段信息都是获取到已经解析好的。代码如下。

public abstract class BaseRecordResolver<T extends FieldHolder> implements RecordResolver<T> {

    private String db;
    private String table;
    private String ddl;
    private Operation operation;
    private FieldHolderMap<T> fieldHolderMap;

    public BaseRecordResolver(Record record) {
        this.operation = record.getOperation();
        String[] dbPair = uncompressionObjectName(record.getObjectName());
        if (null != dbPair) {
            this.db = dbPair[0];
            if (dbPair.length == 2) {
                table = dbPair[1];
            } else if (dbPair.length == 3) {
                table = dbPair[2];
            } else if (dbPair.length == 1) {
                table = "";
            }
        }
        if (record.getOperation() == Operation.DDL) {
            ddl = (String) record.getAfterImages();
        } else if (record.getFields() != null) {
            this.fieldHolderMap = readFieldInfo(record);
        }
    }

    private FieldHolderMap<T> readFieldInfo(Record record) {
        Iterator<Field> fields = ((List<Field>) record.getFields()).iterator();
        Iterator<Object> beforeImages = null;
        Iterator<Object> afterImages = null;
        // update操作没有BeforeImages
        if (record.getOperation() == Operation.UPDATE || record.getOperation() == Operation.DELETE) {
            beforeImages = ((List<Object>) record.getBeforeImages()).iterator();
        }
        // delete操作没有AfterImages
        if (record.getOperation() == Operation.INSERT || record.getOperation() == Operation.UPDATE) {
            afterImages = ((List<Object>) record.getAfterImages()).iterator();
        }
        List<T> fieldHolders = new ArrayList<>(((List<Field>) record.getFields()).size());
        while (fields.hasNext()
                && (beforeImages == null || beforeImages.hasNext())
                && (afterImages == null || afterImages.hasNext())) {
            Field field = fields.next();
            Object before = beforeImages == null ? null : beforeImages.next();
            Object after = afterImages == null ? null : afterImages.next();
            fieldHolders.add(resolverField(field, before, after));
        }
        return new FieldHolderMap<>(fieldHolders);
    }

    @Override
    public String getDdl() {
        if (operation == Operation.DDL) {
            return this.ddl;
        }
        throw new IllegalArgumentException("not found ddl error");
    }

    @Override
    public String getDatabase() {
        return this.db;
    }

    @Override
    public String getTable() {
        return this.table;
    }

    @Override
    public Operation getOperation() {
        return this.operation;
    }

    @Override
    public FieldHolderMap<T> getFields() {
        if (getOperation() == Operation.DDL) {
            throw new IllegalArgumentException("ddl not fields");
        }
        return this.fieldHolderMap;
    }
    // 
    protected abstract T resolverField(Field field, Object before, Object after);
}