去年年中,我接到一个相当头疼的任务:把某返利电商平台原本靠T+1跑批维护的数据链路,改造成能同时承载用户行为分析、订单同步、返利计算和实时报表的数据中台。当时第一反应是“这不就是三套系统吗”,因为用户行为、订单、返利这三块数据,业务口径完全不一样,报表还要求秒级刷新。等项目真正上线,我才摸清楚一条规律:返利平台的实时中台,本质不是堆几个流任务,而是把底层数据的产生、流转、计算、服务一条条理顺。今天把这个建设过程的完整拆解、踩坑记录和可复用经验整理出来,希望能给正在做同类系统的朋友一点参考。
我先交代一下项目背景:这是一个日均订单量几十万量级、用户行为日志每天上亿条的中小型返利平台。业务逻辑不算复杂,但涉及钱、补贴、活动规则以及大量明细对账,所以容错要求很高。整个中台分四条链路:行为采集、订单同步、返利计算、报表服务。每条链路的实时性要求不一样,行为报表要求秒级,返利明细要求分钟级,对账则保留T+1离线校验。先把这个底摸清楚,后面才不会做成一锅粥。
1. 先把返利平台的业务链路拆明白
1.1 用户行为、订单、返利,三张网怎么串起来
返利平台本质是“撮合交易 + 激励机制”的组合:用户通过平台生成的推广链接去电商下单,平台依据订单归属和活动规则给用户返钱。落到数据层面,这几块记录并不是一条单线,而是三张互相咬合的网。
用户行为网是用户从打开页面、浏览商品、点击链接到最终下单的整套动作。埋点会记录大量事件,比如页面曝光、按钮点击、加购、提交订单。这张网的核心价值在于分析转化漏斗、计算推广链接的归因、判断渠道投放效果。
订单网是指所有通过返利链接达成的交易,从下单、支付、发货、确认收货到退款、取消的完整状态变化。订单数据是返利计算的唯一凭证,订单金额怎么算、状态怎么变,直接影响用户拿多少钱。
返利网则是平台自己生成的资金流转记录:根据订单信息、优惠明细、用户等级和当前活动规则,计算出用户可获得的返利金额,然后经历待生效、待结算、可提现、提现中、已提现等多个状态。
这三者的时间流速完全不一样。行为数据是毫秒级流式产生,订单数据是“快照加变更流”,返利数据则更像一张需要不断更新和审计的状态表。如果只是把所有数据丢进一个“中台大池子”,后期算返利一定算不准。合理的做法是先分开建模,再在报表和查询层合并。
1.2 原来跑批链路为什么扛不住了
改造之前,这套系统的数据链路相当传统:埋点日志夜里同步到数仓,订单每天定时拉取,返利报表次日凌晨统一计算。表面看没什么问题,但实际运营里已经积压了一堆抱怨。
第一是用户等返利等得太久。用户在电商平台确认收货后,返利平台这边要第二天早上跑完批才能看到“预计返利到账”。用户来客服问,客服只能回复“请明天再看”,体验非常差。如果碰到热门商品,用户甚至一天问三次。
第二是运营活动没法实时调整。运营同事想在晚上八点大促期间看实时转化和返利支出,但报表始终是昨天的。发现某一档活动补贴超预算,只能等第二天关停,整个活动的风险控制是滞后的。
第三是数据量一上来,凌晨跑批经常超时。几十万订单加上上亿行为日志,多张星型模型宽表关联,跑批时间从一小时慢慢拖到四小时。早上九点开会,十点数据才出齐,运营和财务都在催。
这些痛点叠加起来,已经不再是“优化一下SQL”能解决的,必须在架构层面引入实时计算能力。
1.3 实时中台的架构选型:Lambda 还是 Kappa
做实时中台,绕不开一个经典问题:用 Lambda 还是 Kappa。
Kappa 架构把所有计算都放在流上,实时和重跑统一用一套流任务,理论上最简洁。但返利平台不太适合纯 Kappa,因为返利涉及真金白银,历史对账、财务审计、策略回溯都需要一套离线批处理来兜底。流任务处理漫长的历史数据,成本高、容易卡点,而且一旦规则调整要全量重放,工程复杂度很大。
所以最终我选了 Lambda 混合架构:实时链路负责秒级/分钟级大屏展示、用户提醒和风控监控;离线链路每天执行全量对账和数据修正,确保财务数据准确。两个链路在报表层合并,实时报表看趋势,离线报表出结论。
整体数据流向大致是:客户端和业务库的事件通过消息中间件统一接入,流式ETL做清洗和标准化,实时计算引擎完成用户行为和订单返利的计算,结果写入实时结果表;同时源数据落地到数仓,离线任务每天跑对账和宽表刷新。后面所有章节,都是围绕这条主链路展开的。
2. 用户行为采集与清洗链路
2.1 埋点方案:前端埋点与服务端事件一起上
用户行为是整条链路里数据量最大、格式最杂的部分。一开始团队只做前端埋点,后来发现很多关键动作没法靠前端完整捕获。比如用户在APP内点击了某个返利链接,但网络中断导致事件没上报,或者用户直接用唤起电商App的方式绕过网页,前端根本感知不到。
我的做法是“前端埋点 + 服务端埋点”两边同时做。前端负责记录页面浏览、按钮点击、控件曝光这类纯交互事件;服务端在关键业务节点补充事件,比如“生成推广链接”“跳转电商”“回跳确认参数”。两边通过统一的 eventId 关联,前端产生浏览序列,服务端产生业务结果,合并起来才是完整的行为路径。
埋点事件协议必须从第一天就统一好,字段至少包括:
- eventId:全局唯一事件ID,用于去重
- userId:登录用户ID,未登录的用设备ID
- sessionId:一次会话ID
- eventType:事件类型,枚举值
- eventTime:客户端事件产生时间,ISO8601格式
- productId、channelId、linkId:商品、渠道、推广链接ID
这里有个容易忽略的字段:eventTime。很多埋点SDK默认上传的是“上报时间”,不是“发生时间”。如果后续做延迟分析和会话切分,没有客户端时间根本没法算准。所以协议里必须保留客户端时间,并且和采集服务器接收时间做对比,用于排查时钟偏移。
埋点数据质量也是个长期问题。常见的是重复上报,用户可以手抖点击两次,又或者前端SDK进入后台重新打开后把缓存日志又传了一遍。去重逻辑不能放在最终表,一定要在流上处理掉,否则后面每张报表都会出现虚高。
2.2 消息Topic怎么设计才不乱
行为日志接入后,第一站是消息中间件。按数据域分开建Topic,是所有同类项目里最重要的一条经验。千万不能为了省事把用户行为、订单变更、返利事件塞进同一个Topic,因为下游消费速度和数据保留时间完全不一样。行为数据可以稍作压缩,保留七天就够;订单和返利事件要保证可靠投递,保留时间要更长。
参考我当时的设计,Topic最小集是这样划分的:
- user_behavior:前端和服务端上报的行为明细事件,做标准化和去重
- order_ods:订单库Binlog变更流,记录订单的新增和每次状态更新
- refund_event:退款、取消订单等冲正事件,单独一个Topic便于实时回收返利
- payout_event:提现、打款、失败回滚等资金流转事件
Topic确定后,分区策略也要想清楚。用户行为事件必须按 userId 做分区键,这样同一个用户的行为会进入同一个分区,后续做Session切分和漏斗分析时天然有序。订单事件要按 orderId 做分区键,因为一个订单的状态变更需要严格按顺序消费,否则可能出现“已支付”事件先到、“已创建”事件后到,状态直接被打回。
关于分区数,我吃过亏。初期为了获得高吞吐,把一个Topic设了一百多个分区,结果下游Flink并行度只有十几个,导致每个Task要消费很多分区,反而增加重启恢复成本。后来把核心Topic分区数控制在并行度的两到三倍,既保证扩展性,也能平衡负载。
2.3 流上清洗:标准化、去重和Session切分怎么做
消息进到流计算引擎后,第一步是清洗和标准化。团队用的是Flink SQL做大部分清洗工作,因为SQL声明式逻辑清晰,上线后运营和维护都方便。
标准化要做三件事:字段裁剪,把埋点里用不到的原始字段过滤掉;类型转换,把事件时间从字符串转成时间戳;维度补全,将 productId、channelId 补齐成对应的商品名称、渠道名称。维度补全一般通过临时关联维表实现,维表数据放在缓存里,避免高频访问外部存储。
下面是当时行为清洗作业里比较核心的一段 Flink SQL 逻辑,保留了骨干供参考:
CREATE TABLE behavior_raw ( eventId STRING, userId STRING, sessionId STRING, eventType STRING, eventTime STRING, productId STRING, channelId STRING, procTime AS PROCTIME(), rowTime AS CAST(eventTime AS TIMESTAMP(3)), WATERMARK FOR rowTime AS rowTime - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'user_behavior', 'properties.group.id' = 'behavior_etl_group', 'properties.bootstrap.servers' = '...', 'format' = 'json' ); CREATE TABLE behavior_clean ( eventId STRING PRIMARY KEY NOT ENFORCED, userId STRING, sessionId STRING, eventType STRING, rowTime TIMESTAMP(3), productId STRING, channelId STRING ) WITH ( 'connector' = 'upsert-kafka', 'topic' = 'user_behavior_clean', 'properties.bootstrap.servers' = '...', 'format' = 'json' ); INSERT INTO behavior_clean SELECT eventId, userId, sessionId, eventType, rowTime, productId, channelId FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY eventId ORDER BY rowTime DESC) AS rn FROM behavior_raw ) WHERE rn = 1;去重逻辑我用的是“同一事件ID只保留最新一条”,窗口直接靠 row_number 处理。这个方案在数据量不大时很好用,一旦事件量非常大,分区内窗口状态会膨胀,建议替换成基于布隆过滤器的轻量去重,或者直接使用连接器自带的幂等保证。
Session切分思路比较直接:以 userId 分组,事件之间的间隔超过30分钟就切一个新会话。会话切分不能只靠SQL窗口,因为会话窗口是动态闭合的。我在现实里采用的是状态编程:KeyedState 里记录当前 sessionId 和最后事件时间,新事件进来判断间隔,超过阈值则生成新的 sessionId。会话切分会直接影响后续漏斗准确度,这步值得花时间调。
3. 订单流与返利计算的实时实现
3.1 订单数据实时同步:用CDC而不是轮询查库
订单数据不像行为日志那样天生就是流式事件,它们躺在业务库里,需要想办法同步出来。最容易想到的方案是定时轮询订单表,“每5分钟查一次更新时间大于上次查询时间的记录”。但我强烈不建议这么做,原因有三:
第一,轮询给业务数据库造成持续压力,为了支撑实时同步,等于每个订单要被反复查询多次,高峰期容易拖慢交易接口。第二,轮询只能拿到最新快照,拿不到“从A状态变到B状态”这一过程,返利计算很多时候需要知道状态变更的触发时间。第三,订单删除或修改历史很难捕获,数据对不上账时排查非常痛苦。
最终采用的是CDC方案,也就是订阅数据库的Binlog,把每一条插入、更新、删除操作转换成独立事件放到消息中间件中。订单数据不是“被拉走”,而是“主动流出”,业务库只承受一次主从同步的复制压力。
CDC上线前有个细节必须处理:全量初始化。如果直接从当前Binlog读,只会有增量数据,之前的历史订单全部丢失。正确的流程是先把订单表做一次全量快照同步到ODS层,记录快照时刻的Binlog位点,然后从这个位点开始消费增量。快照和增量会有一小段重叠,需要在下游按 orderId 和时间做幂等合并。
在订单事件里,除了状态字段,我还会额外补充一个“变更前状态”。这样下游不必反查历史表就能判断本次变更是否合法。比如订单从“已支付”跳到“完成”,但没有经过“发货”,这种跳跃就会被实时任务拦截告警。
3.2 返利规则引擎:多级返利怎么落地
订单数据准备完毕后,接下来是重头戏:返利计算。返利计算不是简单拿“订单金额×一个系数”就能完事的,因为平台有大量活动规则、渠道差异、用户等级差异。
举例来说:
- 普通用户一级返利比例是5%,金牌会员是6%;
- 某个品类大促期间额外加1个百分点;
- 用户使用平台优惠券抵扣的部分不计入返利基数;
- 某个渠道来的链接如果用户直接用电商App下单,渠道分成会从3%降到2%。
这些规则在离线计算时代可以用SQL多条分支累加,但实时计算时必须做成可配置的规则集。我的做法是维护一张规则快照表,包含适用范围、渠道ID、用户等级、品类ID、返利比例、生效时间、失效时间。流任务启动时加载全量规则到内存,每天定时刷新一次增量规则。
实时计算返利的时机要选在“确认收货”事件后,因为只有确认收货才意味着订单生效。返利基数使用订单实付金额,也就是商品总价减去平台券和电商优惠后的实际支付金额,但运费不计入返利。流量任务计算时,用订单事件中的多数关键字段关联规则表。
计算过程案例:某一笔订单实付金额127元,其中商品原价150元,电商优惠20元,平台券3元。用户是金牌会员,商品属于数码品类,且当前有数码品类额外加码活动。计算时返利金额 = (127 × 6%) + (127 × 1%) = 8.89元。这里要被捕获的关键点是“平台券抵扣部分不计入返利基数”,如果直接用原价算就会多给用户钱。
在Flink里,我用 KeyedState 保存每个订单的当前状态,字段包括订单ID、用户ID、实付金额、状态、返利金额、相关活动规则版本。这样即使订单状态乱序到达,也能通过状态判断是否已经计算过,避免重复返利。
3.3 返利状态机与退款回收逻辑
返利一旦生成,就进入了一个状态机。状态机的正确设计直接决定后续财务对账是否顺利。
状态定义如下:
- pending:订单已完成,返利已生成,等待结算周期;
- available:返利已进入可提现金额,用户能发起提现;
- withdrawing:用户已发起提现,资金尚未到账;
- paid:提现成功,返利已打款;
- cancelled:订单退款或取消,返利作废回滚。
从 pending 到 available 不一定是自动时间到达,也可能是运营手动审核或者达到最低结算门槛后触发。从 available 到 withdrawing 是用户主动操作。从 withdrawing 到 paid 则是第三方支付回调。
订单退款触发的是 revoked 动作。当收到 refund_event 时,流任务要立刻查找订单状态:
- 如果订单仍处于 pending,直接改成 cancelled;
- 如果已经 available,需要把用户的可提现余额扣减,并生成一条返利冲正记录;
- 如果已经 withdrawing 甚至 paid,那就不能自动扣减了,要进入异常工单,由财务当天人工介入。
这个逻辑非常容易出问题,因为“收到退款事件”和“订单当前状态”之间存在时间差。用户可能已经提现成功了,退款事件才到。此时如果直接负数扣减余额,会造成可提现余额为负,打款批处理直接卡死。所以状态机里必须加入“冲正前是否已提现”的判断分支,不能一刀切。
每次状态流转都必须写审计日志,日志里包含订单ID、操作类型、变更前状态、变更后状态、操作时间、触发源。这样万一返利金额对不上,可以通过日志把整条链路还原。
4. 报表服务:实时数据怎么变成能看的报表
4.1 用户行为报表:秒级大屏和转化漏斗
数据和计算都通了,最终面对用户的是报表。报表分两类,一类是用户行为报表,一类是返利报表。二者对实时性要求不同,建模方式也不同。
用户行为报表最典型的是运营大屏。运营需要在活动期间看到当前实时访客数、页面浏览量、加购人数、支付人数,最好5秒刷新一次。这类报表不能每次都扫明细表聚合,那样数据库扛不住。我采取的做法是实时计算任务直接输出“分维度分时间的预聚合结果”,比如每分钟一行,字段包含渠道ID、页面ID、UV、PV、加购数、下单数。大屏前端只查最近N分钟的汇总,查询压力很小。
展开看,这张预聚合表长这样:
CREATE TABLE behavior_agg_minute ( window_start TIMESTAMP(3), channel_id STRING, page_id STRING, uv BIGINT, pv BIGINT, add_cart_cnt BIGINT, order_cnt BIGINT, PRIMARY KEY (window_start, channel_id, page_id) NOT ENFORCED ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://...', 'table-name' = 'behavior_agg_minute' );UV的实时计算有个坑:不能简单地“按事件去重”,因为一个用户可能跨了多个窗口。正确做法是使用 HyperLogLog 或精确去重状态完成,如果精确度要求很高,就按 userId 分组做窗口去重。我们当时选了近似去重,误差控制在1%以内,大屏展示完全够用。
转化漏斗则需要明细支持,分析师要看从点击到支付的路径。这里不能用预聚合,得提供一张行为明细宽表,查询时按 sessionId 拼接出用户路径。宽表的粒度是“一次会话”,字段包含会话内浏览商品数、是否加购、是否下单、下单金额。因为会话有可能跨整天,实时更新时需要按最后事件时间刷新,入库采用 upsert 模式。
4.2 返利报表的统计口径要提前锁死
返利报表比行为报表更敏感,因为业务和财务都在盯着数字。报表上的每一个指标,必须有统一定义,否则会出现“实时报表显示返利2.3万,离线报表显示2.5万”的扯皮事件。
我整理过一套返利指标口径,这里列一下最核心的:
- 返利基数:符合条件的订单实付金额,平台券抵扣和运费不计入;
- 应付返利:按照规则计算出来、尚未结算给用户的返利总额;
- 已结算返利:已经进入用户可提现余额的返利总额;
- 已提现返利:用户发起提现并完成的返利总额;
- 回收返利:订单退款或者取消后,从待结算/已结算中扣回的返利总额。
实时报表的默认时间窗口是“当前自然日”,统计口径为“今天确认收货的订单所计算的返利”。离线报表则按“结算日期”统计,会考虑当天回退的订单。两套口径各自在报表标题上写清楚,避免混淆。
返利报表的查询维度主要包括渠道ID、品类ID、商品ID、用户等级、活动ID。其中活动ID是报表里最有价值也是容易漏掉的维度,因为运营需要知道每一档活动的补贴到底花了多少钱。我在实时计算中单独维护了活动返利聚合表,每次计算返利时同时更新活动维度的累计值。
4.3 宽表设计与查询加速:列式OLAP周边
返利明细的查询模式很固定:大都先按用户、时间、状态过滤,再看返利金额。行为明细的查询模式更复杂,需要多维组合聚合。两类明细都不适合放在关系型数据库里,所以我们引入了列式OLAP数据库作为统一查询层。
为什么选列式OLAP?因为返利报表每天有几十万行,行为明细每天上亿行,行式数据库做全表聚合时会慢到无法接受。列式存储按列压缩,聚合时只读取需要的列,I/O量大幅减少,亿级数据秒级响应可以做到。
明细宽表的设计按照“维度列在前、指标列在后”的原则。返利明细表举例:
CREATE TABLE refund_detail ( dt DATE, hour INT, order_id BIGINT, user_id BIGINT, channel_id STRING, product_id STRING, category_id STRING, activity_id STRING, order_amount DECIMAL(10,2), base_amount DECIMAL(10,2), rebate_amount DECIMAL(10,2), status STRING, update_time TIMESTAMP ) ENGINE = MergeTree() PARTITION BY dt ORDER BY (dt, user_id, order_id);这里的分区键是日期,排序键是 dt + user_id + order_id。这样用户查询“最近30天我的返利明细”时,查询优化器可以直接裁剪分区,再按 user_id 定位到少量行,性能很好。不要用userId 做分区键,因为订单数量不均衡,会造成某些分区数据过大。
汇总表按天、按小时、按活动维度各建一张,支撑运营和财务的日常查询。每一张汇总表字段都不多,聚合逻辑全部由实时任务在写入时算好,查询端不做重算。
5. 实战中踩过的坑与排查套路
5.1 爆款商品导致的数据倾斜
中台上线一个多月后,运营上线了一个爆款秒杀活动。当天某款商品的用户行为量占到全站70%,结果发现Flink上某个聚合任务开始积压,延迟从几秒涨到十分钟。一查日志,几乎所有数据都落到同一个Key上,形成了典型的数据倾斜。
原因很简单:按商品维度聚合时,爆款商品只有一个并行子任务在计算,其他子任务都在空闲。这个拆法在平时没问题,活动期间就会出事。
解决办法我在项目里用了两套并行手段。第一套是“两阶段聚合”:先把数据按“商品ID + 随机后缀”做一次局部聚合,再把随机后缀去掉做第二次全局聚合。这样能够把热点Key的数据拆散到多个子任务。第二套是加限流保护,对高频维度做本地窗口缓存,先在本机完成预聚合,再打散输出。
两阶段聚合要注意顺序和去重指标的处理,遇到UV这种需要精确去重的指标不能简单拆散,否则会产生误差。我们后来把UV单独用独立任务处理,不走两阶段聚合那条链。
5.2 订单事件迟到、乱序引发状态回跳
实时计算最让人头疼的不是数据量大,而是事件不按顺序到达。一次用户在下单后立刻支付,支付事件可能先到,下单事件反而晚到。如果我们直接按到达顺序处理,订单状态会出现“已支付→已创建→已支付”的跳变,返利计算可能触发两次。
最初的处理方案是给每个订单设置一个“允许乱序时间窗口”,默认5分钟。事件在水位线范围内等待,超过窗口还没到的迟事件走旁路输出。旁路输出不能丢弃,而是进一个“迟到订单修正流”,专门负责把已经算好的结果重新修订。
这个旁路修正流要小心中间态。我遇到过迟到事件触发的订单状态更新,把原本已计算的返利金额又重置了一次,导致返利报表多算了几百块。后来在修正流里加了校验:只有“事件时间大于当前状态的上一次更新时间”才允许覆盖,否则直接忽略。这个规则简单有效,后续所有迟事件都走这套逻辑。
5.3 重复计算和幂等:钱相关不能靠“尽量不重复”
消息中间件是至少一次投递语义,Flink任务重启、输出端重试,都可能导致同一条数据被重复消费。返利数据不是日志,一旦重复计算,用户可能拿双份返利。
我们的原则是“输入去重 + 输出幂等”两头堵。输入侧给每条订单事件标一个唯一事件ID,接收端维护事件ID去重表,只处理首次出现的事件。输出侧则把返利结果写入支持 upsert 的存储,以“订单ID + 返利规则版本”作为唯一键。这样就算任务重启导致重复输出,也只会覆盖旧值,不会产生重复记录。
这里说一个真实经历:有一次我为了赶上线,把订单去重逻辑放在作业最外层,想着先算完再统一去重,结果某个订单收到三次状态变更,返利金额被加了三倍。查了一整天才发现,问题不在计算,在状态更新。后来老老实实把去重拆成两个环节,输入侧按事件ID去重,状态更新侧按状态顺序判断,之后再没犯过同类错误。
5.4 资源与成本治理:实时不等于无限扩容
引入实时中台后,集群资源增长非常快。多个流任务常驻,每个任务为了容错都要开冗余,成本比原来离线跑批高不少。老板看到账单,立刻要求我们做资源治理。
第一件做的是“按需分区”。原先行为Topic有64个分区,但日常QPS根本用不满,保留这么多分区纯粹浪费内存和文件句柄。后来评估后压到32个分区,下游并行度也调到32,成本就降了一截。大促前再临时调高,大促后回缩。
第二件是“复用现有结果”。报表组最初每个大屏指标写一个独立流任务,结果相同的数据源被计算了好几遍。后来把常用指标合并成“多指标单任务”,同一个源数据只消费一次,多个窗口结果一起输出。
第三件是监控消费延迟。以前等用户反馈“报表怎么不动了”才发现任务卡死。后来给每个消费组配了延迟监控,延迟超过阈值就触发告警。这个监控帮我们提前发现了多次任务异常,挽回的返利计算错误远比监控成本高。
做完这套治理后,集群整体成本大约下降了30%,实时性指标还保持原样。
6. 最后分享一点带项目的体会
整个中台从需求梳理到稳定运行,前后花了三个多月。如果现在有人让我再做一遍,我会把更多时间花在“业务口径对齐”上,而不是一上来就设计消息中间件和流任务。返利平台的实时计算,最大的难点不在技术多高深,而是要对清楚每一笔金额该怎么算、什么时候算、算错了怎么回滚。
流计算框架和列式OLAP都是成熟的工具,行业里已经有大量文档和案例。真正考验人的,是那些细节:订单事件乱序怎么办,退款和提现状态交叉怎么处理,报表口径怎么统一。这些坑只有真正跑过线上数据才会遇到,希望这篇梳理能帮后来者少走几步弯路。
这套中台上线后,用户返利到账时间从T+1变成了分钟级,运营大屏秒级刷新,财务对账效率提升明显。我个人最欣慰的不是技术指标变得多好看,而是客服终于不用再对用户说“明天再看”了。