(1)奖励入账:设计与实现八端奖励互通的奖励入账系统,对接多个奖励下游系统,抹平不同奖励下游的差异,对上游屏蔽底层奖励入账细节,设计统一的接口协议提供给业务上游。提供统一的错误处理机制,入账幂等能力和奖励预算控制。
(2)奖励展示/使用:设计与实现活动钱包页,支持在八端展示用户所获得的奖励,支持用户查看、提现(现金),使用卡券/挂件等能力。
(3)基础能力:
-
【基础 sdk】提供查询红包余额、累计收入、用户在春节活动是否获得过奖励等基础 sdk,供业务方查询使用。
-
【预算控制】与上游奖励发放端算法策略打通,实现大流量卡券入账的库存控制能力,防止超发。
-
【提现控制】在除夕当天多轮奖励发放后,提供用户提现的灰度放量能力、提现时尚未入账的处理能力。
-
【运营干预】活动页面灵活的运营配置能力,支持快速发布公告,及时触达用户。为应对黑天鹅事件,支持批量卡券和红包补发能力。
(4)稳定性保障:在大流量的入账场景下,保证钱包核心路径稳定性与完善,通过常用稳定性保障手段如资源扩容、限流、熔断、降级、兜底、资源隔离等方式保证用户奖励方向的核心体验。
(5)资金安全:在大流量的入账场景下,通过幂等、对账、监控与报警等机制,保证资金安全,保证用户资产应发尽发,不少发。
(6)活动隔离:实现内部测试活动、灰度放量活动和正式春节活动三个阶段的奖励入账与展示的数据隔离,不互相影响。
2. 产品需求介绍
用户可以在任意一端参与字节的春节活动获取奖励,以抖音红包雨现金红包入账场景为例,具体的业务流程如下:
登录抖音 → 参与活动 → 活动钱包页 → 点击提现按钮 → 进入提现页面 → 进行提现 → 提现结果页,另外从钱包页也可以进入活动钱包页。
奖励发放核心场景:
-
集卡:集卡抽卡时发放各类卡券,集卡锦鲤还会发放大额现金红包,集卡开奖时发放瓜分奖金和优惠券;
-
红包雨:发红包、卡券以及视频补贴红包,其中红包和卡券最高分别 180w QPS;
-
烟火大会:发红包、卡券以及头像挂件。
3. 钱包资产中台设计与实现
在 2022 年春节活动中,UG 主要负责活动的玩法实现,包含集卡、红包雨以及烟火大会等具体的活动相关业务逻辑和稳定性保障。而钱包方向定位是大流量场景下实现奖励入账、奖励展示、奖励使用与资金安全保障的相关任务。其中资产中台负责奖励发放与奖励展示部分。
3.1 春节资产资产中台总体架构图如下:
钱包资产中台核心系统划分如下:
-
资产订单层:收敛八端奖励入账链路,提供统一的接口协议对接上游活动业务方如 UG、激励中台、视频红包等的奖励发放功能,同时对上游屏蔽对接奖励业务下游的逻辑处理,支持预算控制、补偿、订单号幂等。
-
活动钱包 api 层:收敛八端奖励展示链路,同时支持大流量场景
3.2 资产订单中心设计
核心发放模型:
说明:
-
活动 ID 唯一区分一个活动,本次春节分配了一个单独的母活动 ID
-
场景 ID 和具体的一种奖励类型一一对应,定义该场景下发奖励的唯一配置,场景 ID 可以配置的能力有:发奖励账单文案;是否需要补偿;限流配置;是否进行库存控制;是否要进行对账。提供可插拔的能力,供业务可选接入。
实现效果:
-
实现不同活动之间的配置隔离
-
每个活动的配置呈树状结构,实现一个活动发多种奖励,一种奖励发多种奖励 ID
-
一种奖励 ID 可以有多种分发场景,支持不同场景的个性化配置
订单号设计:
资产订单层支持订单号维度的发奖幂等,订单号设计逻辑为${actID}_${scene_id}_${rain_id}_${award_type}_${statge}
,从单号设计层面保证不超发,每个场景的奖励用户最多只领一次。
4. 核心难点问题解决
4.1 难点一:支持八端奖励数据互通
前文背景已经介绍过了,参与 2022 年春节活动一共有八个产品端,其中抖音系和头条系 APP 是不同的账号体系,所以不能通过用户 ID 打通奖励互通。具体解决方案是字节账号中台打通了八端的账号体系给每个用户生成唯一的 actID(手机号优先级最高,如果不同端登录的手机号一样,在不同端的 actID 是一致的)。钱包侧基于字节账号中台提供的唯一 actID 基础上,设计实现了支持八端奖励入账、查看与使用的通用方案,即每个用户的奖励数据是绑定在 actID 上的,入账和查询是通过 actID 维度实现的,即可实现八端奖励互通。
示意图如下:
4.2 难点二:高场景下的奖励入账实现
每年的春节活动,发现金红包都是最关键的一环,今年也不例外。有几个原因如下:
-
预估发现金红包最大流量有 180w TPS。
-
现金红包本身价值高,需要保证资金安全。
-
用户对现金的敏感度很高,在保证用户体验与功能完整性同时也要考虑成本问题。
终上所述,发现金红包面临比较大的技术挑战。
发红包其实是一种交易行为,资金流走向是从公司成本出然后进入个人账户。
(1)从技术方案上是要支持订单号维度的幂等,同一订单号多次请求只入账一次。订单号生成逻辑为${actID}_${scene_id}_${rain_id}_${award_type}_${statge}
,从单号设计层面保证不超发。
(2)支持高并发,有以下 2 个传统方案:
| 具体方案类型 | 实现思路 | 优点 | 缺点 |
| — | — | — | — |
| 同步入账 | 申请和预估流量相同的计算和存储资源 | 1.开发简单;
2.不容易出错; | 浪费存储成本。 拿账户数据库举例,经实际压测结果:支持 30w 发红包需要 152 个数据库实例,如果支持 180w 发红包,至少需要 1152 个数据库实例,还没有算上 tce 和 redis 等其他计算和存储资源。 |
| 异步入账 | 申请部分计算和存储资源资源,实际入账能力与预估有一定差值 | 1.开发简单;
2.不容易出错;
3.不浪费资源; | 用户体验受到很大影响。 入账延迟较大,以今年活动举例会有十几分钟延迟。用户参与玩法得到奖励后在活动钱包页看不到奖励,也无法进行提现,会有大量客诉,影响抖音活动的效果。 |
以上两种传统意义上的技术方案都有明显的缺点,那么进行思考,既能相对节约资源又能保证用户体验的方案是什么?
最终采用的是红包雨 token 方案,具体方案是使用异步入账加较少量分布式存储和较复杂方案来实现,下面具体介绍一下。
4.2.1 红包雨 token 方案:
本次春节活动在红包雨/集卡开奖/烟火大会的活动下有超大流量发红包的场景,前文介绍过发奖 QPS 最高预估有 180w QPS,按照现有的账户入账设计,需要大量存储和计算资源支撑,根据预估发放红包数/产品最大可接受发放时间,计算得到钱包实际入账最低要支持的 TPS 为 30w,所以实际发放中有压单的过程。
设计目标:
在活动预估给用户发放(180w)与实际入账(30w)有很大 gap 的情况下,保证用户的核心体验。用户在前端页面查看与使用过当中不能感知压单的过程,即查看与使用体验不能受到影响,相关展示的数据包含余额,累计收入与红包流水,使用包含提现等。
具体设计方案:
我们在大流量场景下每次给用户发红包会生成一个加密 token(使用非对称加密,包含发红包的元信息:红包金额,actID,与发放时间等),分别存储在客户端和服务端(容灾互备),每个用户有个 token 列表。每次发红包的时候会在 Redis 里记录该 token 的入账状态,然后用户在活动钱包页看到的现金红包流水、余额等数据,是合并已入账红包列表+token 列表-已入账/入账中 token 列表的结果。同时为保证用户提现体验不感知红包压单流程,在进入提现页或者点击提现时将未入账的 token 列表进行强制入账,保证用户提现时账户的余额为应入账总金额,不 block 用户提现流程。
示意图如下:
token 数据结构:
token 使用的是 pb 格式,经单测验证存储消耗实际比使用 json 少了一倍,节约请求网络的带宽和存储成本;同时序列化与反序列化消耗 cpu 也有降低。
// 红包雨token结构
type RedPacketToken struct {
AppID int64 protobuf: varint,1,opt json: AppID,omitempty
// 端ID
ActID int64 protobuf: varint,2,opt json: UserID,omitempty
// ActID
ActivityID string protobuf: bytes,3,opt json: ActivityID,omitempty
// 活动ID
SceneID string protobuf: bytes,4,opt json: SceneID,omitempty
// 场景ID
Amount int64 protobuf: varint,5,opt json: Amount,omitempty
// 红包金额
OutTradeNo string protobuf: bytes,6,opt json: OutTradeNo,omitempty
// 订单号
OpenTime int64 protobuf: varint,7,opt json: OpenTime,omitempty
// 开奖时间
RainID int32 protobuf: varint,8,opt,name=rainID json: rainID,omitempty
// 红包雨ID
Status int64 protobuf: varint,9,opt,name=status json: status,omitempty
//入账状态
}
token 状态机流转:
在调用账户真正入账之前会置为处理中(2)状态,调用账户成功为成功(8)状态,发红包没有失败的情况,后续都是可以重试成功的。
token 安全性保障:
采用非对称加密算法来保障存储在的客户端尽可能不被破解,其中加密算法为秘密仓库,限制其他人访问。同时考虑极端情况下如果 token 加密算法被黑产破译,可监控报警发现,可降级。
4.2.2 活动钱包页展示红包流水
需求背景:
活动钱包页展示的红包流水是现金红包入账流水、提现流水、c2c 红包流水三个数据源的合并,按照创建时间倒叙排列,需要支持分页,可降级,保证用户体验不感知发现金红包压单过程。
4.3 难点三:发奖励链路依赖多的稳定性保障
发红包流程降级示意图如下:
根据历史经验,实现的功能越复杂,依赖会变多,对应的稳定性风险就越高,那么如何保证高依赖的系统稳定性呢?
解决方案:
现金红包入账最基础要保障的功能是将用户得到的红包进行入账,同时支持幂等与预算控制(避免超发),红包账户的幂等设计强依赖数据库保持事务一致性。但是如果极端情况发生,中间的链路可能会出现问题,如果是弱依赖需要支持降级掉,不影响发放主流程。钱包方向发红包最短路径为依赖服务实例计算资源和 MySQL 存储资源实现现金红包入账。
发红包强弱依赖梳理图示:
| psm | 依赖服务 | 是否强依赖 | 降级方案 | 降级后影响 |
| — | — | — | — | — |
| 资产中台 | tcc | 是 | 降级读本地缓存 | 无 |
|
| bytkekv | 否 | 主动降级开关,跳过 bytekv,依赖下游做幂等 | 无 |
| 资金交易层 | 分布式锁
Redis | 否 | 被动降级,调用失败,直接跳过 | 基本无 |
|
| token
Redis | 否 | 主动降级开关,不调用 Redis | 用户能感知到入账有延迟,会有很多客诉 |
|
| MySQL | 是 | 主有问题,联系 dba 切主 | 故障期间发红包不可用 |
4.4 难点四:大流量发卡券预算控制
需求背景:
春节活动除夕晚上 7 点半会开始烟火大会,是大流量集中发券的一个场景,钱包侧与算法策略配合进行卡券发放库存控制,防止超发。
具体实现:
(1)钱包资产中台维护每个卡券模板 ID 的消耗发放量。
(2)每次卡券发放前算法策略会读取钱包 sdk 获取该卡券模板 ID 的消耗量以及总库存数。同时会设置一个阈值,如果卡券剩余量小于 10%后不发这个券(使用兜底券或者祝福语进行兜底)。
(3) 同时钱包资产中台方向在发券流程累计每个券模板 ID 的消耗量(使用 Redis incr 命令原子累加消耗量),然后与总活动库存进行比对,如果消耗量大于总库存数则拒绝掉,防止超发,也是一个兜底流程。
具体流程图:
优化方向:
(1)大流量下使用 Redis 计数,单 key 会存在热 key 问题,需要拆分 key 来解决。
(2)大流量场景下操作 Redis 会存在超时问题,返回上游处理中,上游继续重试发券会多消耗库存少发,本次春节活动实际活动库存在预估库存基础上加了 5%的量级来缓解超时带来的少发问题。
4.5 难点五:高 QPS 场景下的热 key 的读取和写入稳定性保障
需求背景:
在除夕晚上 7 点半开始会开始烟火大会活动,展示所有红包雨与烟火大会红包的实时累计发放总额,最大流量预估读取有 180wQPS,写入 30wQPS。
这是典型的超大流量,热点 key、更新延迟不敏感,非数据强一致性场景(数字是一直累加),同时要做好容灾降级处理,最后实际活动展示的金额与产品预计发放数值误差小于 1%。
4.5.1 方案一
提供 sdk 接入方式,复用了主会场机器实例的资源。高 QPS 下的读取和写入单 key,比较容易想到的是使用 Redis 分布式缓存来进行实现,但是单 key 读取和写入的会打到一个实例上,压测过单实例的瓶颈为 3w QPS。所以做的一个优化是拆分多个 key,然后用本地缓存兜底。
具体写入流程:
设计拆分 100 个 key,每次发红包根据请求的 actID%100 使用 incr 命令累加该数字,因为不能保证幂等性,所以超时不重试。
读取流程:
与写入流程类似,优先读取本地缓存,如果本地缓存值为为 0,那么去读取各个 Redis 的 key 值累加到一起,进行返回。
问题:
(1)拆分 100 个 key 会出现读扩散的问题,需要申请较多 Redis 资源,存储成本比较高。而且可能存在读取超时问题,不能保证一次读取所有 key 都读取成功,故返回的结果可能会较上一次有减少。
(2)容灾方案方面,如果申请备份 Redis,也需要较多的存储资源,需要的额外存储成本。
4.5.2 方案二
设计思路:
在方案一实现的基础上进行优化,并且要考虑数字不断累加、节约成本与实现容灾方案。在写场景,通过本地缓存进行合并写请求进行原子性累加,读场景返回本地缓存的值,减少额外的存储资源占用。使用 Redis 实现中心化存储,最终大家读到的值都是一样的。
具体设计方案:
每个 docker 实例启动时都会执行定时任务,分为读 Redis 任务和写 Redis 任务。
读取流程:
-
本地的定时任务每秒执行一次,读取 Redis 单 key 的值,如果获取到的值大于本地缓存那么更新本地缓存的值。
-
对外暴露的 sdk 直接返回本地缓存的值即可。
-
有个问题需要注意下,每次实例启动第一秒内是没有数据的,所以会阻塞读,等有数据再返回。
写入流程:
-
因为读取都是读取本地缓存(本地缓存不过期),所以处理好并发情况下的写即可。
-
本地缓存写变量使用 go 的 atomic.AddInt64 支持原子性累加本地写缓存的值。
-
每次执行更新 Redis 的定时任务,先将本地写缓存复制到 amount 变量,然后再将本地写缓存原子性减去 amount 的值,最后将 amount 的值 incr 到 Redis 单 key 上,实现 Redis 的单 key 的值一直累加。
-
容灾方案是使用备份 Redis 集群,写入时进行双写,一旦主机群挂掉,设计了一个配置开关支持读取备份 Redis。两个 Redis 集群的数据一致性,通过定时任务兜底实现。
本方案调用 Redis 的流量是跟实例数成正比,经调研读取侧的服务为主会场实例数 2 万个,写入侧服务为资产中台实例数 8 千个,所以实际 Redis 要支持的 QPS 为 2.8 万/定时任务执行间隔(单位为 s),经压测验证 Redis 单实例可以支持单 key2 万 get,8k incr 的操作,所以设置定时任务的执行时间间隔是 1s,如果实例数更多可以考虑延长执行时间间隔。
具体写入流程图如下:
4.5.3 方案对比
|
| 优点 | 缺点 |
| — | — | — |
| 方案一 | 1. 实现成本简单 | 1. 浪费存储资源;
2. 难以做容灾;
3. 不能做到一直累加; |
| 方案二 | 1. 节约资源;
2. 容灾方案比较简单,同时也节约资源成本; | 1. 实现稍复杂,需要考虑好并发原子性累加问题 |
结论:
从实现效果,资源成本和容灾等方面考虑,最终选择了方案二上线。
4.6 难点六:进行母活动与子活动的平滑切换
需求背景:
为了保证本次春节活动的最终上线效果和交付质量,实际上分了三个阶段进行的。
(1)第一阶段是内部人员测试阶段。
最后
由于文章篇幅原因,我只把面试题列了出来,详细的答案,我整理成了一份PDF文档,这份文档还包括了还有 高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料 ,帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习。
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!