订阅更新

维度表离线和实时都要用,维护两份又怕不一致——维度服务怎么设计?

数仓项目手册 第 7 题 约 11 分钟读完

本文目录
  1. 先说结论
  2. 维度服务整体架构
  3. 方案一:预加载到Flink State(适合小维度表)
  4. 方案二:旁路查Redis缓存(适合中等维度表)
  5. 方案三:Flink Temporal Join(适合大维度表)
  6. 三种方案对比
  7. 面试高频追问
  8. 实战踩坑
  9. 总结

先说结论

维度表的核心原则是'一个源头、多端同步'。所有维度数据以MySQL(或业务系统)为唯一源头,通过同步机制分发到Hive(离线用)、Redis(实时用)、OLAP引擎(查询用)。

实时维度关联的三种方案:① 预加载到Flink State(小维度表);② 旁路查Redis/MySQL缓存(中等维度表);③ Flink Temporal Join(大维度表,有版本需求)。

不要手动维护多份维度表!手动同步一定会出问题,必须用自动化同步机制。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

上一篇讲了口径一致性,其中有一个关键细节——维度表的一致性。在Lambda架构中,离线链路的维度表在Hive里,实时链路的维度表在Redis或MySQL里,两边怎么保持同步?如果渠道表更新了一个新渠道,离线能查到但实时查不到,JOIN出来就是NULL。这种问题我在某短剧出海公司遇到过好几次,最终设计了一套统一的维度服务方案,今天分享出来。

维度服务整体架构

我在某短剧出海公司设计的维度服务架构如下:

维度数据流向(一个源头、多端同步):
 
                    ┌─→ Hive/MaxCompute(离线DWD JOIN用)
                    │   每天全量同步一次
                    │
MySQL(唯一源头)──→ Canal ──→ Kafka ─┤
                    │   ├─→ Redis(实时Flink JOIN用)
                    │   │   实时增量同步
                    │   │
                    │   └─→ Hologres(OLAP查询用)
                    │       实时增量同步
                    │
                    └─→ DataWorks全量同步(兜底)
                        每天凌晨全量覆盖,确保一致性

核心思路是:MySQL的变更通过Canal采集到Kafka,Kafka消费端分别写入Redis和Hologres(实时同步);同时每天凌晨通过DataWorks做一次全量同步到Hive和Hologres(离线兜底)。这样无论维度表怎么更新,各端在几秒到几分钟内都能看到最新数据。

方案一:预加载到Flink State(适合小维度表)

如果维度表很小(几千到几万行,比如渠道表、国家表、币种表),最简单的方案是启动时全量加载到Flink的State中,然后定期刷新。

// Flink预加载维度表到State
public class DimChannelFunction extends RichMapFunction<Event, EnrichedEvent> {
    private Map<String, ChannelInfo> channelCache;
 
    @Override
    public void open(Configuration params) {
        // 启动时从MySQL全量加载渠道维度表
        channelCache = loadFromMySQL("SELECT * FROM dim_channel");
        // 注册定时器,每5分钟刷新一次
        getRuntimeContext().getScheduledExecutor()
            .scheduleAtFixedRate(this::refreshCache, 5, 5, TimeUnit.MINUTES);
    }
 
    @Override
    public EnrichedEvent map(Event event) {
        ChannelInfo channel = channelCache.get(event.getChannelId());
        return new EnrichedEvent(event, channel);  // 维度退化
    }
 
    private void refreshCache() {
        channelCache = loadFromMySQL("SELECT * FROM dim_channel");
    }
}

适用场景:维度表<10万行、更新频率低(每天几次)、不需要精确的版本控制。我们的渠道表只有50行,国家表200多行,用这种方案最省事。

不适用:维度表太大(百万行+)会占用大量Task内存;更新非常频繁(每秒级别)会导致MySQL压力大。

方案二:旁路查Redis缓存(适合中等维度表)

如果维度表有几十万到几百万行,预加载到State不现实,可以用Flink的异步IO查Redis。维度数据由同步任务实时写入Redis,Flink在处理每条事件时异步查询Redis获取维度信息。

// Flink异步IO查Redis维度表
public class AsyncDimLookup extends RichAsyncFunction<Event, EnrichedEvent> {
    private transient RedisAsyncCommands<String, String> redis;
 
    @Override
    public void open(Configuration params) {
        RedisClient client = RedisClient.create("redis://redis-host:6379");
        redis = client.connect().async();
    }
 
    @Override
    public void asyncInvoke(Event event, ResultFuture<EnrichedEvent> future) {
        // 异步查Redis:key = dim:user:{user_id}
        String key = "dim:user:" + event.getUserId();
        redis.hgetall(key).thenAccept(fields -> {
            UserInfo user = parseUserInfo(fields);
            future.complete(Collections.singleton(
                new EnrichedEvent(event, user)));
        }).exceptionally(e -> {
            // Redis查询失败时用默认值,不要阻塞流
            future.complete(Collections.singleton(
                new EnrichedEvent(event, UserInfo.UNKNOWN)));
            return null;
        });
    }
}

适用场景:维度表10万-500万行、需要实时更新(Redis通过Canal实时同步)、查询量大但可以接受毫秒级延迟。

关键优化点:① Redis用Hash结构存维度数据,一个key存一行,查询O(1);② 设置合理的TTL防止脏数据残留;③ asyncInvoke的超时时间设短一点(50-100ms),避免Redis慢查询阻塞Flink。

方案三:Flink Temporal Join(适合大维度表)

如果维度表非常大或者需要版本控制(比如Type2拉链表),可以用Flink SQL的Temporal Join。维度表以CDC流的方式接入Flink,Flink内部维护一份最新快照:

-- Flink SQL Temporal Join示例
-- 维度表:从Kafka CDC流创建
CREATE TABLE dim_product (
    product_id STRING,
    product_name STRING,
    category STRING,
    price DECIMAL(10,2),
    update_time TIMESTAMP(3),
    WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND,
    PRIMARY KEY (product_id) NOT ENFORCED
) WITH (
    'connector' = 'kafka',
    'topic' = 'cdc_dim_product',
    'format' = 'debezium-json'
);
 
-- 事实流JOIN维度表(用Processing Time)
SELECT
    e.event_id, e.user_id,
    e.product_id, d.product_name, d.category, d.price,
    e.event_time
FROM event_stream e
LEFT JOIN dim_product FOR SYSTEM_TIME AS OF e.proc_time d
    ON e.product_id = d.product_id;

三种方案对比

维度预加载State旁路查RedisTemporal Join
维度表大小<10万行10万-500万行任意大小
更新延迟分钟级(定时刷新)秒级(CDC同步Redis)秒级(CDC流直接消费)
开发复杂度低(简单Map函数)中(异步IO+Redis运维)高(CDC+Flink SQL)
Flink资源消耗高(全部加载到内存)低(外部存储)中(维护State快照)
版本控制不支持不支持支持(可以关联历史版本)
我的选择渠道表、国家表用户表、设备表商品表(需要历史价格)

面试高频追问

追问一:维度表更新时,正在处理的数据会不会JOIN到旧版本?

会。这是一个经典的'维度更新一致性'问题。比如商品价格从10元改成了15元,在价格更新的那一秒,有一条订单事件正好在处理,它可能JOIN到旧价格10元。对于大多数业务场景,这种短暂的不一致是可以接受的——因为离线链路会兜底修正。但如果是财务级别的精确需求,就需要用Temporal Join + Event Time来关联'事件发生时'的维度版本,这样就能保证关联到正确的历史价格。

追问二:Redis挂了怎么办?Flink会不会阻塞?

不会阻塞,因为我们用的是异步IO + 超时降级。asyncInvoke设了100ms超时,Redis查询超时或失败时返回默认值(UserInfo.UNKNOWN)。这样Flink不会阻塞,但关联结果是默认值。这些'维度缺失'的记录会被标记出来,离线链路用Hive维度表重新关联修正。我们还做了Redis的高可用——用Redis Sentinel做主从切换,主节点挂了10秒内自动切换到从节点。

追问三:维度表同步延迟怎么监控?

我们做了一个简单的心跳机制:同步任务每分钟往Redis写一个心跳key(dim:heartbeat = 当前时间戳),监控系统每分钟检查这个key的时间戳跟当前时间的差值。差值>5分钟就告警,说明同步链路可能出问题了。Hive的全量同步也有类似检查——如果凌晨3点前没有同步完成,触发告警。

实战踩坑

我在某短剧出海公司遇到过一次维度缺失的事故。某天运营新增了一个广告渠道'snapchat',在后台系统录入后MySQL已经有了,但Redis的同步任务因为Canal挂了没有及时同步。结果实时大屏上这个渠道的数据全部显示为'未知渠道',运营以为投放没生效,差点紧急暂停了Snapchat的投放计划。

排查发现是Canal的连接MySQL的Binlog位点丢了,重启后跳过了那条INSERT语句。修复方案:① 紧急手动同步dim_channel到Redis;② 长期方案是加了Canal的位点监控和自动恢复机制;③ 每天凌晨的全量同步也增加了Redis的全量覆盖(之前只同步Hive),作为二重兜底。

# 紧急修复:手动同步维度表到Redis
# 1. 从MySQL导出渠道维度表
mysql -h db-host -u reader -p -e \
  "SELECT channel_id, channel_name, channel_type FROM dim_channel" \
  > /tmp/dim_channel.tsv
 
# 2. 批量写入Redis(用pipeline提速)
while IFS=$'\t' read -r id name type; do
  echo "HSET dim:channel:${id} name ${name} type ${type}"
done < /tmp/dim_channel.tsv | redis-cli --pipe
 
# 3. 验证
redis-cli HGETALL dim:channel:snapchat
# 预期输出:name snapchat type social

总结

维度服务的设计核心是'一个源头、多端同步、自动化运维'。小表用预加载State,中表用旁路查Redis,大表或需要版本控制的用Temporal Join。面试时不要只说'我们把维度表放Redis里查'——要能说出为什么选Redis而不是其他方案、同步机制怎么设计、挂了怎么降级、怎么监控同步延迟。这些细节才是真正做过的人才能说出来的。

维度服务搞定了,回到数据源头——每天TB级的数据怎么接入数仓?下一篇聊数据接入层的设计,包括Binlog采集、日志采集和埋点数据的统一管理。

你们的维度表用的哪种关联方案?有没有遇到过维度缺失的事故?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器