作者:李文杰
在不少的支付分析场景里,大部分累计值指标可以通过 T+n 的方式计算得到 。随着行业大环境由增量市场转为存量市场,产品的运营要求更加精细化、更快速反应,这对各项数据指标的实时性要求已经越来越高。产品如果能实时把握应用的整体运行情况或特征用户的状态,就可以及时安排合理的市场营销活动,这对改善用户的体验和促进收益的增长有明显的帮助。
有一个场景为了进一步优化营销活动内容,希望我们实时提供每个玩家在最近 1 年、2 年、5 年、10 年内的实时消费总金额。
要实时计算每个玩家最近 N 年的实时消费累计总金额,一方面要考虑到这个指标随着时间推进它可能在不断增加,另一方面会有数据过期了而不再属于这个统计周期内,要及时减去,从而维护一个动态的累计值。
这里的每一个用户的“最近 N 年”指标是不断前进的,涉及到产品上线以来的全部用户,其累计的用户量、支付数据都在亿级别以上,且明确要求实时统计历史数据。综合分析下来,解决该问题具有一定的挑战性。
在经过充分调研和分析后,基于实时计算框架 Flink 和分布式数据库 TiDB 的组合使用,我们提出了一种实时计算滑动窗口内累计指标的算法,在一个数据库里同时支持实时 OLAP 计算和 OLTP 数据服务,有效地解决了这个问题,目前已经在线上稳定服务了一段时间。下面给大家分享下我们的思考和实践。
首先我们先从整体上评估下数据的特点,分析一下数据规模、有哪些关键问题对我们的计算有影响。
线上的应用部署在不同的机器上,先后请求的数据的业务时间和日志打印时间,可能是乱序的,这会导致我们需要解决数据排序的问题。且由于业务存在请求重试逻辑,数据也有可能是重复的,需要设计好去重机制。
主要的问题在于对于统计最近一段时间内的值,这个“最近”是实时变化的,即统计区间的开始、结束时间点也是实时变化的,这个问题可能就比较复杂了,需要严格保证每个操作的原子性和隔离性,而且每笔数据不能重复算也不能漏算,否则就会出现数据错误。
该方案是指,当查询某个用户最近 N 年的累计值的请求发送过来时,直接到数据库统计得到结果,可以理解为是一个用户级的实时 AP 操作。这种方法在良好的表设计、索引设计下,大部分场景在秒级别可以完成查询,在并发高时数据库资源很容易出现算力瓶颈,导致服务不稳定,业务受影响。
总的来说,实时统计这个算法实现起来相对简单,但服务容易因算力问题影响,实时性不能保证,尤其是高并发场景容易出现问题,线上实时数据服务慎用该策略。
该方案提前将全部用户的最近 N 年的累计值算好,并缓存起来,业务方可以实时读取这个缓存,也能支持高并发实时响应。然后计算侧根据实时变化的情况,更新每个用户指标值。如果是在统计周期内用户有新增数据,则在缓存值基础上累加,如果在统计周期内有用户的数据过期了,则在缓存值的基础上减去。总之,总是维护好用户的实时累计值。
实时全量缓存方案,解决了实时全量统计的实时性和高并发访问的问题,但是也带来了数据操作的事务性、安全性等问题,有一定的可取之处,但缺点也很明显。
考虑到业务侧是 OLTP 的访问特性,要求支持低延迟高并发,提供点查的方式才是最高效的。
该方案在数据初始化时先提前算好全部用户的累计值,并存储到关系型数据库,再基于数据库的基量数据进行实时的增量更新操作。如果是在统计周期内用户有新增数据,则在基量值上累加,如果在统计周期内有用户的数据过期了,则在基量值上减去,一直基于实时的变化量来维护最新的累计值。
综合考虑之后,我们选用了全量持久化+实时增量的方案。
目前业界领域内处理实时数据的技术工具,选用 Flink 应该是毫无疑义的。数据库方面选型,我们需要考虑下面的场景:
满足这些苛刻要求的数据库其实不多,分布式数据库 TiDB 就是其中一个非常优秀的选项 ,它能很好地满足上面的场景需求。
我们计算用户最近 N 年的累计值,这里有两个关键要素,一个是统计时间周期,一个是用户。
下面我们以统计时间周期为分析切入点,引入时间窗口来解决我们的统计问题。
一段固定长度的时间区间,即我们需求里说的“最近 N 年”,我们可以称其为一个时间窗口。如果一个时间窗口支持随着时间变化,那这个窗口就是动态变化的,根据动态变化的情况会有许多细分的窗口类型,用以解决不同场景的问题。下面主要介绍和我们业务相关度较高的滑动窗口和会话窗口。
滑动窗口是固定长度的时间窗口,随着时间变化以一定的频率前进,它们之间允许有重叠。 滑动窗口的滑动距离(window slide)可以控制生成新窗口的频率。 如果 slide 小于窗口大小,不同的滑动窗口会有部分重叠。这种情况下,一个数据点可能被多个窗口包含在内。
如上图所示,比如我们设置了窗口的大小为 10 分钟,每 5 分钟滑动一次,则会在每 5 分钟后得到一个新的窗口, 且新窗口会包含一部分在之前的窗口里出现过的数据。
在滑动时间窗口中,我们通常要选择窗口大小和滑动步长。窗口大小指的是每个子时间段的长度,而滑动步长则指的是相邻子时间段之间的时间间隔。根据具体的场景,我们可以调整窗口大小和滑动步长,使得滑动时间窗口更好地适应不同的数据流处理需求。
这个数据模型,很符合我们的统计最近 N 年的实时累计值的场景。“最近 1 年”、“最近 2 年”、“最近 5 年”、“最近 10 年”就是我们的窗口大小,滑动步长是实时,这里为了分析方便,我们每 1 分钟滑动一次,即每分钟都会产生一个最近 N 年的滑动窗口。
与滑动窗口不同,会话窗口会为活跃数据创建窗口,会话窗口不会相互重叠,没有固定的开始或结束时间。我们可以设置固定的会话间隔(session gap)来定义多长时间算作不活跃。 当超出了不活跃的时间段,当前的窗口就会关闭,并且将接下来的数据分发到新的会话窗口。
在我们的场景,相当于对每个用户维护一个永远不关闭的会话窗口,方便实时监听“最近”的情况,但会话窗口的开始时间不好跟随时间变化而动态设置。同时考虑到我们要分析的数据量在百万级以上,要实时维护这么多的会话窗口,资源消耗会比较多,难度会比较大。所以,会话窗口不合适我们的计算场景。
综合考虑后,我们选择了滑动窗口模型来开展我们的计算。这种处理技术常用于实时数据分析和流媒体处理中。它可以帮助我们对数据流中的信息进行实时监听并分析,能够快速响应数据流的变化。
下面详细描述具体的计算过程。
基于滑动窗口模型,结合我们的数据特性,定义了一个滑动的统计时间窗口,如下图。
最近 N 年的统计周期长度,由统计区间的开始时间 T1 (左边界) 和 T2 (右边界)共同决定,时间长度 N = T2 - T1 始终保持固定,即左右边界的间隔是固定不变的。
窗口的右边界 T2 随着时间变化,不断实时向前滑动,同时也牵引着整个窗口向前滑动。如下图所示,我们设定固定的前进频率为 Delta t ,窗口随该频率不断向前滑动,前进的步调频率最快可以到秒级,但是为了保证读取到的数据稳定性以及应对上游数据可能存在延迟的情况,我们通常设置为 30 秒或 1 分钟。
读取到线上日志数据写入到 TiDB 中生成基础数据时,我们借助 TiDB 关系型数据库的特性,解决数据排序、重复的问题。
TiDB 不仅解决了海量数据的存储,还保证了优秀的读写性能。上游业务可以保证相同用户在同一时刻不会出现支付多笔的情况,为了防止极端情况的出现,Flink 使用串行 Sink 的方式写入基础数据,经过对几十亿行历史日志数据的重放入库验证,每一行数据都有严格的递增入库时间,可以保证其单调递增特性,同时也能达到万级的写入QPS性能。这是我们下面按时间切片来计算的关键所在。
为了保证同一个用户在相同步调下执行操作,我们起一条 Flink 计算流,流里设置两个 Source 和两个 Sink 分别负责指标累计值的加、减操作,Sink 时借助 TiDB 的悲观事务特性,整个过程可以保证操作的事务性和计算可重入。
在写入数据的时候,如果是首次计算则需要插入,如果不是首次写入则要求更新多列,于是我们使用了 INSERT ON DUPLICATE KEY UPDATE 方式执行加、减的操作,同时为了避免锁冲突而影响写效率,设置单线程串行的 Sink 行为。
为了保证可重入和 Exactly Once 要求,即经过窗口边界的数据只计算一次。我们在 TiDB 数据库层面,在结果指标表内,我们通过对每个用户的指标设置两个水位线字段,分别标识最近一次的已经执行过的左边界、右边界数据。
利用 TiDB 写操作不阻塞读的特性,不管计算任务多么繁忙,只要不影响数据库性能,那线上服务都可以实时读到最新的结果指标,不会影响线上服务可用性,这一点也是 TiDB 非常优秀的地方。
下面我们通过不同场景来阐述该算法。
1)如上图所示,窗口在前一个统计窗口内容累计总金额值为 100,在经过一次滑动后,有一笔充值金额为 30 的新订单进入了统计周期内,体现在这笔订单的入库时间小于当前窗口的右边界,那么我们的计算 FLink 作业就能读取到该值,并在相应用户的累计值上执行加操作,得到实时的最近 N 年累计总充值指标。
2)同理,如下图,如果是有一笔数据随着窗口滑动而过期了,此时这笔订单的入库时间在最近 N 年之前,我们的计算 FLink 作业就能读取到该值,并在相应用户的累计值上执行减操作,得到实时的最近 N 年累计总充值指标。
3)更复杂的计算场景,如下图,如果随着窗口滑动同时有新数据进入,也有旧数据过期,那么流里设置的两个 Source 和两个 Sink 分别负责指标累计值的加、减操作。由于基础数据源是严格有序的和在 Sink 时设置了串行操作,同时我们将加、减操作放在了 TiDB 内执行,而 TiDB 具有优秀的事务机制保证,所以我们左、右边界的操作是相互独立的事务,互不影响。如果同时有多条新数据、多条过期数据,基础数据的有序性和 Sink 的事务性也可以保证数据的正常处理。
该基于 TiDB + Flink 的实时累计指标算法,目的是解决”最近一段时间的实时累计指标“的计算问题。
经过一些调整或优化,它也可以适用于很多的计算场景,如:
如果有任何问题,欢迎一起交流探索!