Flink双流实时对账

背景

在电商、金融、银行、支付等涉及到金钱相关的领域,为了安全起见,一般都有对账的需求。

比如,对于订单支付事件,用户通过某宝付款,虽然用户支付成功,但是用户支付完成后并不算成功,我们得确认平台账户上是否到账了。

针对上述的场景,我们可以采用批处理,或离线计算等技术手段,通过定时任务,每天结束后,扫描数据库中的数据,核对当天的支付数据和交易数据,进行对账。

想要达到实时对账的效果,比如有的用户支付成功但是并没有到账,要及时发出报警,我们必须得依赖实时计算框架。

我们将问题简单化,比如有如下场景,在某电商网站,用户创建订单并支付成功,会将相关信息发给kafka,字段包括,用户uid、动作、订单id、时间等信息

{userId=1, action='create', orId='order01', timestamp=1606549513} 
{userId=1, action='pay', orId='order01', timestamp=1606549516} 
{userId=2, action='create', orId='order02', timestamp=1606549513}

支付成功并且金额已经进入平台账户,往往也会把相关信息发给kafka,如订单id,支付方式、时间等信息。

{orId='order01', payChannel='wechat', timestamp=1606549536}
{orId='order02', payChannel='alipay', timestamp=1606549537}

只有订单在支付(action=pay)成功后,并且成功到账,这才算一次完整的交易。本案例,就是要实时检测那些不成功的交易,如有不成功的,及时发出报警信息。

上述行为本身会产生两种事件流,一种是订单事件流,另一种是交易事件流,我们通过Flink将两种类型的流进行关联,实时分析没有到账的数据,发出报警。

为了简化,我们从socket读取数据流,代替从kafka消费数据。

代码示例

本案例涉及到的知识点:

  • 状态编程
  • 定时器
  • 延迟事件处理
  • 合流操作

首先,我们需要定义订单事件OrderEvents和交易事件ReceiptEvents

// 订单事件
public class OrderEvents {
    // 用户id
    private Long userId;
    // 动作
    private String action;
    // 订单id
    private String orId;
    // 时间 单位 s
    private Long timestamp;
    // get/set
}
// 交易事件
public class ReceiptEvents {
    // 订单id
    private String orId;
    // 支付渠道
    private String payChannel;
    // 时间 单位 s
    private Long timestamp;
    // get/set
}

通过Flink程序,联合两条流,实时检测交易失败的数据并输出到侧输出流里。

public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        // 定义测输出流,输出只有pay事件没有receipt事件的异常信息
        OutputTag payEventTag = new OutputTag<String>("payEventTag-side") {};
        // 定义测输出流,输出只有receipt事件没有pay事件的异常信息
        OutputTag receiptEventTag = new OutputTag<String>("receiptEventTag-side") {};

        // 读取订单数据
        KeyedStream<OrderEvents, String> orderStream = env.socketTextStream("localhost", 8888).map(new MapFunction<String, OrderEvents>() {
            @Override
            public OrderEvents map(String value) throws Exception {
                String[] split = value.split(",");
                return new OrderEvents(Long.parseLong(split[0]), split[1], split[2], System.currentTimeMillis() / 1000);
            }
        }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<OrderEvents>() {
            @Override
            public long extractAscendingTimestamp(OrderEvents element) {
                return element.getTimestamp() * 1000;
            }
        }).filter(new FilterFunction<OrderEvents>() {
            @Override
            public boolean filter(OrderEvents value) throws Exception {
                return value.getAction().equals("pay");
            }
        }).keyBy(new KeySelector<OrderEvents, String>() {
            @Override
            public String getKey(OrderEvents value) throws Exception {
                return value.getOrId();
            }
        });

        // 读取交易数据
        KeyedStream<ReceiptEvents, String> receiptStream = env.socketTextStream("localhost", 9999).map(new MapFunction<String, ReceiptEvents>() {
            @Override
            public ReceiptEvents map(String value) throws Exception {
                String[] split = value.split(",");
                return new ReceiptEvents(split[0], split[1], System.currentTimeMillis() / 1000);
            }
        }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<ReceiptEvents>() {
            @Override
            public long extractAscendingTimestamp(ReceiptEvents element) {
                return element.getTimestamp() * 1000;
            }
        }).keyBy(new KeySelector<ReceiptEvents, String>() {
            @Override
            public String getKey(ReceiptEvents value) throws Exception {
                return value.getOrId();
            }
        });

        // connect两条流
        SingleOutputStreamOperator<String> process = orderStream.connect(receiptStream).process(new MyCoProcessFunction());

        // 输出正常交易的数据
        process.print("success");
        // 输出异常交易的数据
        process.getSideOutput(payEventTag).print("payEventTag");
        process.getSideOutput(receiptEventTag).print("receiptEventTag");

        env.execute("Tx Match job");
    }

上面代码的主要逻辑是:

  • 从端口为8888和9999的两个socket读取订单事件和交易事件(模拟从kafka消费),然后将事件数据包装成OrderEvents和ReceiptEvents。

  • 提取事件时间。

  • 对于OrderEvents,只需要action=pay的数据,过滤无用的数据。

  • 将两条流根据orId keyby,生成orderStream和receiptStream,并通过connect合并两条流,将合并后的结果,交给CoProcessFunction函数计算。

  • 将正常交易事件输出在success中,异常的交易事件,输出到两个侧输出流中。

所以,我们需要自定义聚合函数,继承CoProcessFunction函数,实现正常交易和异常交易行为的实时计算。

class MyCoProcessFunction 
        extends CoProcessFunction<OrderEvents, ReceiptEvents, String> {

    // 定义测输出流,输出只有pay事件没有receipt事件的异常信息
    OutputTag payEventTag = new OutputTag<String>("payEventTag-side") {};
    // 定义测输出流,输出只有receipt事件没有pay事件的异常信息
    OutputTag receiptEventTag = new OutputTag<String>("receiptEventTag-side") {};

    // 定义状态,保存订单pay事件和交易事件
    ValueState<OrderEvents> payEventValueState = null;
    ValueState<ReceiptEvents> receiptEventValueState = null;

    @Override
    public void open(Configuration parameters) throws Exception {
        ValueStateDescriptor<OrderEvents> descriptor1
                = new ValueStateDescriptor<OrderEvents>("payEventValueState", OrderEvents.class);
        ValueStateDescriptor<ReceiptEvents> descriptor2
                = new ValueStateDescriptor<ReceiptEvents>("receiptEventValueState", ReceiptEvents.class);
        payEventValueState = getRuntimeContext().getState(descriptor1);
        receiptEventValueState = getRuntimeContext().getState(descriptor2);
    }

    // 处理OrderEvents事件
    @Override
    public void processElement1(OrderEvents orderEvents, Context ctx, Collector<String> out) throws Exception {
        if (receiptEventValueState.value() != null) {
            // 正常输出匹配
            out.collect("订单事件:"+orderEvents.toString() + "和交易事件:" + receiptEventValueState.value().toString());
            receiptEventValueState.clear();
            payEventValueState.clear();
        } else {
            // 如果没有到账事件,注册定时器等待
            payEventValueState.update(orderEvents);
            ctx.timerService().registerEventTimeTimer(orderEvents.getTimestamp() * 1000 + 5000L); // 5s
        }
    }

    // 处理receipt事件
    @Override
    public void processElement2(ReceiptEvents receiptEvents, Context ctx, Collector<String> out) throws Exception {
        if (payEventValueState.value() != null) {
            // 正常输出
            out.collect("订单事件:"+payEventValueState.value().toString() + "和交易事件:" + receiptEvents.toString()+" 属于正常交易");
            receiptEventValueState.clear();
            payEventValueState.clear();
        } else {
            // 如果没有订单事件,说明是乱序事件,注册定时器等待
            receiptEventValueState.update(receiptEvents);
            ctx.timerService().registerEventTimeTimer(receiptEvents.getTimestamp() * 1000 + 3000L); // 3s
        }
    }

    // 定时器
    @Override
    public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
        // 判断哪个状态存在,表示另一个事件没有来
        if (payEventValueState.value() != null) {
            ctx.output(payEventTag, payEventValueState.value().toString() + " 有pay事件没有receipt事件,属于异常事件");
        }

        if (receiptEventValueState.value() != null) {
            ctx.output(receiptEventTag, receiptEventValueState.value().toString() + " 有receipt事件没有pay事件。属于异常事件");
        }
        receiptEventValueState.clear();
        payEventValueState.clear();
    }
}

上述代码是我们自定义的窗口函数,主要的功能是:

  • 继承了CoProcessFunction,分别在processElement1和procossElement2方法中处理orderEvents和receiptEvent。

  • 定义状态,侧输出流,注册定时器,通过一些逻辑计算是是正常交易还是异常交易。

  • 在processElement1方法中,如果只有pay事件没有receipt事件,则注册一个5s后触发的定时器,等待receipt事件的到来,如果5s后receipt事件仍没有到来,则说明是一个异常交易事件,触发timer并将异常事件输出到侧输出流中。

  • 在processElement2方法中,如果只有receipt事件没有pay事件,表明pay事件和receipt事件乱序,则注册一个3s的定时器,等待pay事件。如果3s后还是没有pay事件到达,则触发timer将延迟的乱序数据输出到侧输出流中。

  • 定义定时器timer,对于异常的交易行为,将交易输出输出到侧输出流。异常交易是指,在一定时间范围内,只有pay事件没有receipt事件 或 只有receipt事件没有pay事件。如果在一定时间范围内这两个事件都有,则属于正常交易行为。

打开两个socket,输入数据模拟交易行为。为了输出一些异常信息,我们的输入方式,不光要正常输入数据,还要输入一些乱序的数据,比如只输入payEvent不输入receiptEvent等,使之触发timer。

输入订单事件

nc -lk 8888
1,pay,orderId01
2,pay,orderId02
3,pay,orderId03
4,pay,orderId04
6,pay,orderId06
7,pay,orderId07
8,pay,orderId0

输入交易事件

nc -lk 9999
orderId01,wechat
orderId03,alipay
orderId04,wechat
orderId05,alipay
orderId06,wechat
orderId08,alipa

控制台输出:

success> 订单事件:OrderEvents{userId=1, action='pay', orId='orderId01', timestamp=1606555301}和交易事件:ReceiptEvents{orId='orderId01', payEquipment='wechat', timestamp=1606555307} 属于正常交易
success> 订单事件:OrderEvents{userId=3, action='pay', orId='orderId03', timestamp=1606555318}和交易事件:ReceiptEvents{orId='orderId03', payEquipment='alipay', timestamp=1606555325} 属于正常交易
payEventTag> OrderEvents{userId=2, action='pay', orId='orderId02', timestamp=1606555313} 有pay事件没有receipt事件,属于异常事件
success> 订单事件:OrderEvents{userId=4, action='pay', orId='orderId04', timestamp=1606555332}和交易事件:ReceiptEvents{orId='orderId04', payEquipment='wechat', timestamp=1606555338} 属于正常交易
success> 订单事件:OrderEvents{userId=6, action='pay', orId='orderId06', timestamp=1606555351}和交易事件:ReceiptEvents{orId='orderId06', payEquipment='wechat', timestamp=1606555358} 属于正常交易
receiptEventTag> ReceiptEvents{orId='orderId05', payEquipment='alipay', timestamp=1606555345} 有receipt事件没有pay事件。属于异常事件
success> 订单事件:OrderEvents{userId=8, action='pay', orId='orderId08', timestamp=1606555375}和交易事件:ReceiptEvents{orId='orderId08', payEquipment='alipay', timestamp=1606555382} 属于正常交易
payEventTag> OrderEvents{userId=7, action='pay', orId='orderId07', timestamp=1606555368} 有pay事件没有receipt事件,属于异常事件
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容