在实际生产中,我们经常会有这样的需求,需要以原始数据流作为基础,然后关联大量的外部表来补充一些属性。例如,我们在订单数据中,希望能得到订单收货人所在省的名称,一般来说订单中会记录一个省的 ID,那么需要根据 ID 去查询外部的维度表补充省名称属性。

在 Flink 流式计算中,我们的一些维度属性一般存储在 MySQL/HBase/Redis 中,这些维表数据存在定时更新,需要我们根据业务进行关联。根据我们业务对维表数据关联的时效性要求,有以下几种解决方案:

  • 实时查询维表关联
  • 预加载维表关联
  • 热存储关联
  • 其他

实时查询维表关联

实时查询维表是指用户在 Flink 算子中直接访问外部数据库,比如用 MySQL 来进行关联,这种方式是同步方式,数据保证是最新的。但是,当我们的流计算数据过大,会对外部系统带来巨大的访问压力,一旦出现比如连接失败、线程池满等情况,由于我们是同步调用,所以一般会导致线程阻塞、Task 等待数据返回,影响整体任务的吞吐量。而且这种方案对外部系统的 QPS 要求较高,在大数据实时计算场景下,QPS 远远高于普通的后台系统,峰值高达十万到几十万,整体作业瓶颈转移到外部系统。

这种方式的核心是,我们可以在 Flink 的 Map 算子中建立访问外部系统的连接。下面以订单数据为例,我们根据下单用户的城市 ID,去关联城市名称,核心代码实现如下:

public class Order {
    private Integer cityId;
    private String userName;
    private String items;
    private String cityName;
	...
}

public class DimSync extends RichMapFunction<String,Order> {
    private static final Logger LOGGER = LoggerFactory.getLogger(DimSync.class);
    private Connection conn = null;
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/dim?characterEncoding=UTF-8", "admin", "admin");
    }
    public Order map(String in) throws Exception {
        JSONObject jsonObject = JSONObject.parseObject(in);
        Integer cityId = jsonObject.getInteger("city_id");
        String userName = jsonObject.getString("user_name");
        String items = jsonObject.getString("items");
        //根据city_id 查询 city_name
        PreparedStatement pst = conn.prepareStatement("select city_name from info where city_id = ?");
        pst.setInt(1,cityId);
        ResultSet resultSet = pst.executeQuery();
        String cityName = null;
        while (resultSet.next()){
            cityName = resultSet.getString(1);
        }
        pst.close();
        return new Order(cityId,userName,items,cityName);
    }
    public void close() throws Exception {
        super.close();
        conn.close();
    }
}

在上面这段代码中,RichMapFunction 中封装了整个查询维表,然后进行关联这个过程。需要注意的是,一般我们在查询小数据量的维表情况下才使用这种方式,并且要妥善处理连接外部系统的线程,一般还会用到线程池。最后,为了保证连接及时关闭和释放,一定要在最后的 close 方式释放连接,否则会将 MySQL 的连接数打满导致任务失败。

预加载维表关联

全量预加载数据是为了解决每条数据流经我们的数据系统都会对外部系统发起访问,以及对外部系统频繁访问而导致的连接和性能问题。这种思路是,每当我们的系统启动时,就将维度表数据全部加载到内存中,然后数据在内存中进行关联,不需要直接访问外部数据库。

这种方式的优势是我们只需要一次性地访问外部数据库,大大提高了效率。但问题在于,一旦我们的维表数据发生更新,那么 Flink 任务是无法感知的,可能会出现维表数据不一致,针对这种情况我们可以采取定时拉取维表数据。并且这种方式由于是将维表数据缓存在内存中,对计算节点的内存消耗很高,所以不能适用于数量很大的维度表。

方案1 RichMapFunction 中实现

我们还是用上面的场景,根据下单用户的城市 ID 去关联城市名称,核心代码实现如下:

public class WholeLoad extends RichMapFunction<String,Order> {

    private static final Logger LOGGER = LoggerFactory.getLogger(WholeLoad.class);
    ScheduledExecutorService executor = null;
    private Map<String,String> cache;

    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        executor.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    load();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        },5,5, TimeUnit.MINUTES);//每隔 5 分钟拉取一次维表数据
    }

    @Override
    public Order map(String value) throws Exception {
        JSONObject jsonObject = JSONObject.parseObject(value);
        Integer cityId = jsonObject.getInteger("city_id");
        String userName = jsonObject.getString("user_name");
        String items = jsonObject.getString("items");
        String cityName = cache.get(cityId);
        return new Order(cityId,userName,items,cityName);
    }

    public void load() throws Exception {
        Class.forName("com.mysql.jdbc.Driver");
        Connection con = DriverManager.getConnection("jdbc:mysql://localhost:3306/dim?characterEncoding=UTF-8", "admin", "admin");
        PreparedStatement statement = con.prepareStatement("select city_id,city_name from info");
        ResultSet rs = statement.executeQuery();
        //全量更新维度数据到内存
        while (rs.next()) {
            String cityId = rs.getString("city_id");
            String cityName = rs.getString("city_name");
            cache.put(cityId, cityName);
        }
        con.close();
    }
}

在上面的例子中,我们使用 ScheduledExecutorService 每隔 5 分钟拉取一次维表数据。这种方式适用于那些实时场景不是很高,维表数据较小的场景。

优点:实现简单

缺点:仅支持小数据量维表

适用场景:维表小,变更频率低,对变更及时性要求低

方案2 分发本地文件

通过 Distributed Cache 分发本地维度文件到task manager后加载到内存关联。

实现方式

通过env.registerCachedFile注册文件。

实现RichFunction,在open()中通过RuntimeContext获取cache文件。

解析和使用文件数据。

优点:不需要外部数据库

缺点:支持维度数据量比较小,更新需要更改文件并重启作业

适用场景:维度数据是以文件形式,数据量小,更新频率低。比如:静态码表,配置文件。

热存储关联

实时流与热存储上维度数据关联, 使用 cache 减轻存储访问的压力.

实现方式: 将维度数据导入热存储 Redis/Tair/HBase/ES, 通过异步 IO 查询热存储, 利用 cache 机制将维度数据缓存在内存:

flinkcdc MySQL 多表 压力 flink 多表关联_数据库

在这里推荐使用 Guava 库提供的 CacheBuilder 来创建我们的缓存:

CacheBuilder.newBuilder()
        //最多存储10000条
        .maximumSize(10000)
        //过期时间为1分钟
        .expireAfterWrite(60, TimeUnit.SECONDS)
        .build();

整体的实现思路是:我们利用 Flink 的 RichAsyncFunction 读取 Hbase 的数据到缓存中,我们在关联维度表时先去查询缓存,如果缓存中不存在这条数据,就利用客户端去查询 Hbase,然后插入到缓存中。

首先我们需要一个 Hbase 的异步客户端:

<dependency>
    <groupId>org.hbase</groupId>
    <artifactId>asynchbase</artifactId>
    <version>1.8.2</version>
</dependency>

核心代码如下:

public class LRU extends RichAsyncFunction<String,Order> {

    private static final Logger LOGGER = LoggerFactory.getLogger(LRU.class);
    String table = "info";
    Cache<String, String> cache = null;
    private HBaseClient client = null;
    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        //创建hbase客户端
        client = new HBaseClient("127.0.0.1","7071");
        cache = CacheBuilder.newBuilder()
                //最多存储10000条
                .maximumSize(10000)
                //过期时间为1分钟
                .expireAfterWrite(60, TimeUnit.SECONDS)
                .build();
    }

    @Override
    public void asyncInvoke(String input, ResultFuture<Order> resultFuture) throws Exception {

        JSONObject jsonObject = JSONObject.parseObject(input);
        Integer cityId = jsonObject.getInteger("city_id");
        String userName = jsonObject.getString("user_name");
        String items = jsonObject.getString("items");
        //读缓存
        String cacheCityName = cache.getIfPresent(cityId);

        if(cacheCityName != null){
            Order order = new Order();
            order.setCityId(cityId);
            order.setItems(items);
            order.setUserName(userName);
            order.setCityName(cacheCityName);
            resultFuture.complete(Collections.singleton(order));
        }else {
            //如果缓存获取失败再从hbase获取维度数据
            client.get(new GetRequest(table,String.valueOf(cityId))).addCallback((Callback<String, ArrayList<KeyValue>>) arg -> {
                for (KeyValue kv : arg) {
                    String value = new String(kv.value());
                    Order order = new Order();
                    order.setCityId(cityId);
                    order.setItems(items);
                    order.setUserName(userName);
                    order.setCityName(value);
                    resultFuture.complete(Collections.singleton(order));
                    cache.put(String.valueOf(cityId), value);
                }
                return null;
            });

        }
    }

}

这里需要特别注意的是,我们用到了异步 IO (RichAsyncFunction),这个功能的出现就是为了解决与外部系统交互时网络延迟成为系统瓶颈的问题。

我们在流计算环境中,在查询外部维表时,假如访问是同步进行的,那么整体能力势必受限于外部系统。正是因为异步 IO 的出现使得访问外部系统可以并发的进行,并且不需要同步等待返回,大大减轻了因为网络等待时间等引起的系统吞吐和延迟问题。

我们在使用异步 IO 时,一定要使用异步客户端,如果没有异步客户端我们可以自己创建线程池模拟异步请求。

优点:维度数据不受限于内存,支持较多维度数据

缺点:需要热存储资源,维度更新反馈到结果有延迟(热存储导入,cache)

适用场景:维度数据量大,可接受维度更新有一定的延迟。

其他

除了上述常见的处理方式,我们还可以通过:

  • 将维表消息广播出去;
  • 自定义异步线程池访问维表;
  • 自己扩展 Flink SQL 中关联维表的方式, 直接使用 SQL Join 方法关联查询结果。

果没有异步客户端我们可以自己创建线程池模拟异步请求。

优点:维度数据不受限于内存,支持较多维度数据

缺点:需要热存储资源,维度更新反馈到结果有延迟(热存储导入,cache)

适用场景:维度数据量大,可接受维度更新有一定的延迟。

其他

除了上述常见的处理方式,我们还可以通过:

  • 将维表消息广播出去;
  • 自定义异步线程池访问维表;
  • 自己扩展 Flink SQL 中关联维表的方式, 直接使用 SQL Join 方法关联查询结果。

总体来讲,关联维表的方式就以上几种方式,并且基于这几种方式还会衍生出各种各样的解决方案。我们在评价一个方案的优劣时,应该从业务本身出发,不同的业务场景下使用不同的方式。