一、需求场景
Flink 流式计算中,常需以原始数据流为基础,关联外部维表补充属性,满足业务分析需求。
示例:订单数据流中仅包含收货人所在省的 ID,需关联外部维表(存储省 ID 与省名称的映射关系),补充省名称属性,完善订单数据维度。
实际业务中,维表数据通常存储在 MySQL、HBase、Redis 等外部系统,且存在定时更新需求,需根据业务时效性要求选择合适的关联方案。
常用维表关联方案主要分为以下4类,基本覆盖所有业务场景:
-
实时查询维表
-
预加载全量数据
-
LRU 缓存
-
其他衍生方案
二、实时查询维表
2.1 核心原理
实时查询维表是指在 Flink 算子中直接访问外部数据库(如 MySQL),采用同步调用方式,确保获取的维表数据是最新的。核心逻辑是在 Flink 算子(如 Map 算子)中建立外部系统连接,每条数据流触发一次外部查询,完成关联。
2.2 优缺点
-
优点:数据实时性最高,确保关联的维表数据是最新状态;
-
缺点:
-
高并发场景下,会对外部系统造成巨大访问压力,QPS 需求极高(峰值可达十万到几十万),作业瓶颈转移至外部系统;
-
同步调用易导致线程阻塞、Task 等待数据返回,影响整体任务吞吐量;
-
外部系统异常(如连接失败、线程池满)会直接影响 Flink 任务稳定性。
-
2.3 代码实现(订单关联城市名称)
以订单数据为例,根据下单用户的城市 ID,关联 MySQL 维表获取城市名称,核心代码如下:
2.3.1 订单实体类
java
public class Order {
private Integer cityId;
private String userName;
private String items;
private String cityName; // 关联维表后补充的属性
// 无参构造
public Order() {}
// 带参构造(关联后使用)
public Order(Integer cityId, String userName, String items, String cityName) {
this.cityId = cityId;
this.userName = userName;
this.items = items;
this.cityName = cityName;
}
// getter/setter 方法
public Integer getCityId() {
return cityId;
}
public void setCityId(Integer cityId) {
this.cityId = cityId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getItems() {
return items;
}
public void setItems(String items) {
this.items = items;
}
public String getCityName() {
return cityName;
}
public void setCityName(String cityName) {
this.cityName = cityName;
}
@Override
public String toString() {
return "Order{" +
"cityId=" + cityId +
", userName='" + userName + '\'' +
", items='" + items + '\'' +
", cityName='" + cityName + '\'' +
'}';
}
}
2.3.2 实时查询维表算子
java
import org.apache.flink.api.common.functions.RichMapFunction;
import org.apache.flink.configuration.Configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson.JSONObject;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class DimSync extends RichMapFunction<String, Order> {
private static final Logger LOGGER = LoggerFactory.getLogger(DimSync.class);
private Connection conn = null; // 外部数据库连接
/**
* 初始化方法:Task启动时执行,创建数据库连接
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 建立MySQL连接(需引入MySQL驱动依赖)
conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/dim?characterEncoding=UTF-8",
"admin",
"admin"
);
}
/**
* 核心处理方法:每条数据触发一次维表查询
*/
@Override
public Order map(String in) throws Exception {
// 解析输入的JSON格式订单数据
JSONObject jsonObject = JSONObject.parseObject(in);
Integer cityId = jsonObject.getInteger("city_id");
String userName = jsonObject.getString("user_name");
String items = jsonObject.getString("items");
// 根据city_id查询MySQL维表,获取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(); // 关闭PreparedStatement,避免资源泄露
// 返回关联后的订单数据
return new Order(cityId, userName, items, cityName);
}
/**
* 关闭方法:Task结束时执行,释放数据库连接
*/
@Override
public void close() throws Exception {
super.close();
if (conn != null && !conn.isClosed()) {
conn.close(); // 释放连接,避免打满MySQL连接数
}
}
}
2.4 注意事项
-
适用场景:仅适用于小数据量维表查询,避免高并发场景下压垮外部系统;
-
连接管理:需妥善处理外部系统连接,建议使用线程池管理连接,避免单条数据创建一个连接;
-
资源释放:必须在
close\(\)方法中释放数据库连接、PreparedStatement 等资源,防止连接泄露导致外部系统连接数耗尽,进而引发任务失败。
三、预加载全量数据
3.1 核心原理
为解决实时查询维表带来的外部系统频繁访问、连接压力大的问题,预加载全量数据采用"一次性加载+定时更新"的思路:
-
Flink 任务启动时,将外部维表的全量数据加载到内存中;
-
数据流处理时,直接在内存中进行关联,无需访问外部数据库;
-
通过定时任务拉取维表最新数据,更新内存中的缓存,解决维表数据更新无法感知的问题。
3.2 优缺点
-
优点:仅需一次性访问外部系统,大幅降低外部系统压力,关联效率极高(内存查询);
-
缺点:
-
内存消耗大:全量维表数据缓存于内存,不适用于数据量较大的维表;
-
数据延迟:定时更新机制导致维表数据存在延迟,无法实时获取最新数据;
-
维表更新感知不及时:若维表数据更新频繁,定时更新间隔难以平衡延迟与性能。
-
3.3 代码实现(定时拉取城市维表)
java
import org.apache.flink.api.common.functions.RichMapFunction;
import org.apache.flink.configuration.Configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson.JSONObject;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class WholeLoad extends RichMapFunction<String, Order> {
private static final Logger LOGGER = LoggerFactory.getLogger(WholeLoad.class);
private ScheduledExecutorService executor; // 定时任务线程池
private Map<String, String> cache; // 内存缓存,存储city_id与city_name的映射
/**
* 初始化方法:启动定时任务,定时拉取维表数据
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 初始化缓存(可使用HashMap、ConcurrentHashMap等线程安全集合)
cache = new ConcurrentHashMap<>();
// 初始化定时任务线程池,每隔5分钟拉取一次维表数据
executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(
this::load, // 定时执行的方法(拉取维表数据)
5, // 延迟5秒后开始第一次执行
5, // 每次执行间隔5分钟
TimeUnit.MINUTES
);
// 首次启动时主动拉取一次数据
load();
}
/**
* 核心处理方法:从内存缓存中查询维表数据,完成关联
*/
@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");
// 从内存缓存中获取city_name(无需访问外部数据库)
String cityName = cache.get(String.valueOf(cityId));
return new Order(cityId, userName, items, cityName);
}
/**
* 维表数据拉取方法:从MySQL拉取全量维表数据,更新内存缓存
*/
public void load() throws Exception {
Class.forName("com.mysql.jdbc.Driver"); // 加载MySQL驱动
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();
// 清空原有缓存,更新为最新数据(避免数据冗余)
cache.clear();
while (rs.next()) {
String cityId = rs.getString("city_id");
String cityName = rs.getString("city_name");
cache.put(cityId, cityName);
}
// 释放资源
rs.close();
statement.close();
con.close();
LOGGER.info("维表数据拉取完成,缓存大小:{}", cache.size());
}
/**
* 关闭方法:停止定时任务,释放资源
*/
@Override
public void close() throws Exception {
super.close();
if (executor != null && !executor.isShutdown()) {
executor.shutdown(); // 停止定时任务线程池
}
}
}
3.4 注意事项
-
适用场景:维表数据量小、实时性要求不高,且数据更新频率较低的场景;
-
缓存选择:需使用线程安全的集合(如 ConcurrentHashMap),避免多线程操作缓存出现数据异常;
-
定时间隔:根据维表更新频率设置合理的定时拉取间隔,平衡数据延迟与系统性能;
-
资源释放:停止任务时需关闭定时任务线程池,避免线程泄露。
四、LRU 缓存
4.1 核心原理
LRU(Least Recently Used,最近最少使用)是一种缓存淘汰算法,核心逻辑是:当缓存容量达到上限时,淘汰最近最少使用的冷数据,保留查询频率高的热数据。
Flink 中使用 LRU 缓存关联维表的思路:
-
利用 Flink 的
RichAsyncFunction(异步 IO)读取外部维表(如 HBase)数据到 LRU 缓存; -
关联时优先查询缓存,缓存命中则直接返回结果;
-
缓存未命中时,通过异步客户端查询外部维表,将结果插入缓存后返回,同时触发 LRU 淘汰机制(若缓存满)。
补充:异步 IO(RichAsyncFunction)的核心作用是解决与外部系统交互时,网络延迟成为系统瓶颈的问题,通过并发异步请求,避免同步等待导致的线程阻塞,提升任务吞吐量。
4.2 优缺点
-
优点:
-
缓存热数据,减少外部系统访问次数,降低网络延迟和外部系统压力;
-
异步 IO 支持并发请求,提升任务吞吐量;
-
缓存容量可控,通过淘汰冷数据,降低内存消耗。
-
-
缺点:
-
数据存在延迟:缓存数据有过期时间,维表数据更新后无法实时同步;
-
实现复杂度高于前两种方案,需配置缓存参数、异步客户端。
-
4.3 依赖与代码实现
4.3.1 依赖引入(HBase 异步客户端)
xml
<!-- HBase 异步客户端依赖 -->
<dependency>
<groupId>org.hbase</groupId>
<artifactId>asynchbase</artifactId>
<version>1.8.2</version>
</dependency>
<!-- Guava 缓存依赖(用于实现LRU缓存) -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
4.3.2 LRU 缓存与异步关联代码
java
import org.apache.flink.api.common.functions.RichAsyncFunction;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.util.Collector;
import org.hbase.async.GetRequest;
import org.hbase.async.HBaseClient;
import org.hbase.async.KeyValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson.JSONObject;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class LRU extends RichAsyncFunction<String, Order> {
private static final Logger LOGGER = LoggerFactory.getLogger(LRU.class);
private static final String HBASE_TABLE = "info"; // HBase维表名称
private Cache<String, String> lruCache; // LRU缓存
private HBaseClient hbaseClient; // HBase异步客户端
/**
* 初始化方法:创建HBase客户端和LRU缓存
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 1. 初始化HBase异步客户端(指定HBase地址和端口)
hbaseClient = new HBaseClient("127.0.0.1", "7071");
// 2. 初始化LRU缓存
lruCache = CacheBuilder.newBuilder()
.maximumSize(10000) // 缓存最大容量(10000条)
.expireAfterWrite(60, TimeUnit.SECONDS) // 缓存过期时间(1分钟)
.build(); // 默认使用LRU淘汰算法
}
/**
* 异步处理方法:异步查询缓存/维表,完成关联
*/
@Override
public void asyncInvoke(String input, Collector<Order> resultFuture) throws Exception {
// 解析输入的JSON格式订单数据
JSONObject jsonObject = JSONObject.parseObject(input);
Integer cityId = jsonObject.getInteger("city_id");
String userName = jsonObject.getString("user_name");
String items = jsonObject.getString("items");
String cityIdStr = String.valueOf(cityId);
// 1. 先查询LRU缓存
String cacheCityName = lruCache.getIfPresent(cityIdStr);
if (cacheCityName != null) {
// 缓存命中,直接返回关联结果
Order order = new Order();
order.setCityId(cityId);
order.setItems(items);
order.setUserName(userName);
order.setCityName(cacheCityName);
resultFuture.collect(order);
return;
}
// 2. 缓存未命中,异步查询HBase维表
hbaseClient.get(new GetRequest(HBASE_TABLE, cityIdStr))
.addCallback((List<KeyValue> arg) -> {
// 回调函数:HBase查询完成后执行
for (KeyValue kv : arg) {
String cityName = new String(kv.value());
// 构建关联后的订单数据
Order order = new Order();
order.setCityId(cityId);
order.setItems(items);
order.setUserName(userName);
order.setCityName(cityName);
// 发送结果
resultFuture.collect(order);
// 将查询结果存入LRU缓存,供后续使用
lruCache.put(cityIdStr, cityName);
}
return null;
});
}
/**
* 关闭方法:释放客户端和缓存资源
*/
@Override
public void close() throws Exception {
super.close();
if (hbaseClient != null) {
hbaseClient.shutdown(); // 关闭HBase异步客户端
}
if (lruCache != null) {
lruCache.invalidateAll(); // 清空缓存
}
}
}
4.4 注意事项
-
异步 IO 使用:必须搭配异步客户端(如 HBase 异步客户端),若无异步客户端,可自行创建线程池模拟异步请求,避免同步等待;
-
缓存配置:根据业务场景合理设置缓存容量和过期时间,平衡内存消耗与数据延迟;
-
异常处理:需添加回调异常处理逻辑,避免 HBase 查询失败导致任务阻塞;
-
适用场景:维表数据量大、存在明显冷热数据区分,且实时性要求中等的场景。
五、其他衍生方案
除上述三种核心方案外,可根据业务需求衍生出多种维表关联方式,主要包括:
-
维表广播:将维表数据封装为广播流,与原始数据流进行广播连接,适用于维表数据量小、更新频率低的场景,可避免重复查询;
-
自定义异步线程池:通过创建自定义线程池,模拟异步请求,解决无异步客户端的外部系统关联问题;
-
Flink SQL 扩展:扩展 Flink SQL 的维表关联功能,直接使用 SQL Join 语法关联外部维表(如 MySQL、HBase),简化开发流程,适用于 SQL 化开发场景。
六、方案选型建议
维表关联方案的选型需结合业务场景,核心关注三个维度:维表数据量、实时性要求、外部系统性能,具体建议如下:
-
实时性要求极高、维表数据量小 → 实时查询维表;
-
实时性要求低、维表数据量小 → 预加载全量数据;
-
实时性要求中等、维表数据量大、存在冷热数据 → LRU 缓存(搭配异步 IO);
-
SQL 化开发、维表数据量适中 → Flink SQL 扩展维表关联;
-
维表数据量小、更新频率低 → 维表广播。
总体而言,各类方案可相互结合衍生新的解决方案,核心是从业务本身出发,平衡数据实时性、系统性能和开发复杂度。