广告系统转化结算事务问题记录

广告系统转化结算模块功能: 展示事件触发相关信息写入DDB, 发生转化事件时查找对应的展示信息, 一连串业务逻辑之后标记转化并生成结算订单. 后续需要计算金额.

结算模块功能功能, 但是运行久了有各种细节问题需要完善, 尤其是需要服务冗余保证高可用时, 遇到了不少边边角角的业务逻辑的事务问题. 这里记录一下.

延迟结算问题

由于展示请求量大, 展示队列写入DDB的延迟偶尔会比较高. 因此收到回调请求时, 对应的展示有可能还未写入DDB. 在这种情况下, 需要对回调进行重试. 做法是做优先级重试队列: redis sorted set, 以结算时间为score, 构造一个优先级队列. 并定期将最老需要重试的结算请求取出重试. 在单结算模块部署的情况下, ZRANGE… + ZREMBY … 不会出现问题. 然而在多服务部署情况下, 就会存在原子性的问题. redis 5.0 之后, 对于sorted set提供了原子性的操作, ZPOP, BZPOP, 可以解决问题. 旧版本的redis, 通过pipeline的命令来实现原子性.

分区问题

之前版本的结算模块, 从Kinesis分Shard来消费, Kinesis自动根据写入的内容来进行分区, 所以相同的请求, 在被消费时一定时保证时序性的. 系统演化后, 我们改用Kafka作为数据队列, 而Kafka写入的时候, 如果不指定键值, 默认是采用均匀分发的方式写入不同partition的, 这就不能保证相同的请求在消费时的时序性. 此外, 我们的回调请求中包含唯一的会话ID, 以及回调时间戳, 两个回调请求, 会话ID相同, 但是回调时间戳可以不同, 所以我们不能基于整个请求来做分区, 而是需要跟据会话ID来做. 所以对于带有partition的数据队列服务, 我们一定要明确时间/消息的顺序性, 并设计好partition的方式.

先查后改的事务问题

对于连续相同的回调请求, 之前我们已经注意到了最终一致性的问题. 但是仍然出现了重复结算. 问题在于结算逻辑:

  1. 先检查是否已经标记结算 (redis + ddb)
  2. 如果没有, 走正常结算逻辑
  3. 标记结算, 并写入订单队列

其中, 阶段2我们假定是很短的(毫秒级), 但是在多结算服务场景下 (开了goroutine), 极端情况下 (被刷回调), 重复的结算请求, 被同时处理, 同时走到1, 2, 导致结算重复. 解决办法: 3标记结算这一步先利用redis SET NX 的方式来做, 如果标记失败, 整个结算逻辑回滚.

事务逻辑

事务的基本操作, 就是先读, 再更新. 严格一点, 所有读到的数据需要锁住 (即repeatable read serialization). 最简单的方式, 就是永远只有一个在处理, 自然没有是无逻辑, 但是性能不能满足. 因此, 我们弱化一点要求, 不是严格的限制, 允许脏读.

例子: 各种实时预算规则, 如判断该渠道是否超了每日金额, 限流, 利润率控制, 等等核心业务规则. 需要各规则都检查通过的前提下才予以结算.

这种短期金额数据, 目前是通过redis汇总, 但是必须在订单成功写入后才能更新. 我们做法是, 当订单写入kafka结算队列持久化后, 我们就认为结算成功, 在整个结算逻辑过程中只做读操作, 并维护一个需要结算成功的回调函数进行相关数据更新的操作, 在写入成功后逐一apply. 再严谨一点需要在更新逻辑判断目标值是否和查询时一致, 实际上并没有做, 因为哪怕不一致, 也没法回滚了. 这会导致实际控下来比目标多一点的情况.

另外的办法: 校验每个规则的时候如果过了直接扣掉, 但是如果最终不通过还要补偿回去, 这中比较悲观的办法, 可以做到严格不超预算计划, 但是会误杀掉很多, 实际执行下来会比目标低不少. 以及考虑到不通过规则的占比其实很高, 属于吃力不讨好的办法.

后续优化

实际上真正有效的结算QPS并不高 (高就发财了), 现状引入了较多的异步操作以优化性能, 属于想太多的设计. 为什么有那么多相同的结算请求? 我也很无奈, 要么是对接第三方的BUG, 要么是被恶意刷了, 通过尝试exploit重复结算漏洞来获取利益.

可以针对回调请求接收环节, 做一定的分控规则, 从而从源头上解决重复结算请求. 对于有效的结算, 永远只做单进程处理并保活, 从而避免掉各种细节上要考虑事务的复杂操作.

HOME