背景
B站直播平台近年来迅猛发展,为了吸引更多观众和优质主播,直播平台通常会推出多样玩法(包括节日活动、任务、榜单、抽奖),以激励用户参与和创造高质量内容,拉动平台营收,丰富直播生态。诸多玩法都存在一个共同的场景:给用户发放奖励。为了满足奖品种类以及发放场景的多样性,我们设计了通用奖励系统,用于支撑各类上层业务。本文将介绍直播奖励系统的技术架构,从需求分析到实现细节,全面解析其背后的技术方案。
图片
需求分析
通用奖励系统的设计需要从分别从需求侧和供给侧两个方向入手:一是发放场景接入,二是奖品类型接入,奖励系统为二者制订了接入标准,方便进行后续的扩展维护。
图片
从业务接入来看,奖励系统需要满足各类上游场景的发放,如:榜单截榜后发奖、任务完成发奖、抽奖发奖、竞猜开奖发奖等诸多场景,因此接入方式既要足够标准化又要兼容差异化。
从奖品类型来看,奖励存在多样性,既包含头像框、头衔、房间皮肤等权益类奖品,又包含电池、金仓鼠、包裹道具等价值类奖品,不同的奖品各自所需的配置属性不同,由不同的下游发放,且每个下游吞吐能力也不一致,奖励系统在处理奖励发放时需要分别设置不同的发放限流阈值以防止击垮下游。
从发放时效来看,业务能级有高低之分,高能级的业务场景发放延迟会造成用户体验下降,甚至会引发客诉舆情,因此需要有独立的发放通道专门用于保障高优业务。
最后作为一个值得被信任的奖励系统,一定要满足这三个特点:低延迟、不超发、不漏发。
架构介绍
基于奖励上游业务方多、峰值流量高、稳定性要求高的特点,为求高峰值流量下奖励系统的稳定可靠,方案选型时选择了基于消息队列削峰、异步处理请求的总体方案。以下是奖励系统整体架构图:
图片
系统架构包括:
- 接入层:负责制定业务接入标准,提供业务源注册、奖励发放、回收、奖品佩戴、取消佩戴等功能;
- 配置层:按照业务所需配置各类奖励包裹,如:排行榜发奖、主播任务发奖、奖池抽奖发奖,同时还负责新奖励类型的配置接入;
- 服务层:处理接入层的奖励请求,拆分包裹,记录发放状态,管理发奖二级队列,调度不同的奖品下游进行奖励发放;
- 存储层:架构中依赖的存储方案
- databus 作为MQ提升吞吐量
- mysql负责存储基本配置、奖品和包裹的映射关系以及发放记录
- taishanKV存储奖品扩展配置
- redis 负责提供二级队列、延迟重试队列
- hdfs 存储奖励配置和发放记录离线数据,提供分析、筛查等功能
- 离线层:数平oneservice数据检索、扫描异常发放记录状态补发奖励、系统日志、相关数据埋点
详细设计
发奖流程按照:业务上游 → 一级队列 → 二级队列 → 发放下游 四个模块串行,简单直观,业务方仅需配置奖励,再调用接口投递发奖消息即可,非常便捷,串行流程如下图所示:
针对需求中的三个特点系统分别进行了如下设计:
- 低延迟:提供不同通道的隔离发放,针对高优业务可选择Fast通道,防止发放量过大的低优业务影响高优业务的发奖时长;
- 不超发:奖励中心设计了全链路幂等,上游消息携带 source + msg_id,中间写库设置DB唯一键防止重复消费,下游提供支持幂等的发放接口,确保奖励无论怎么重试都不会被超发;
- 不漏发:层层重试,因奖励系统存在多重异步,针对每一步都设置相应的重试策略,避免中途链路中断产生漏发。
古希腊哲学家赫拉克利特曾说过:"The only constant is change.",意思就是:唯一不变的是变化。这句话提醒我们要接受变化和不确定性,并在这种情况下保持适应能力,设计系统也是如此,外部的变量太多,不能仅仅停留在理想状态思考问题,因此奖励系统在容错上做了不少细节设计:
1.快慢队列
某天凌晨收到大量客诉,反馈直播奖励到账延迟,排查下来是运营上了一个用户观看直播10min获得抽奖券的任务,0点10分过后,大量的发奖消息涌入队列,当时奖励系统没做消息隔离,造成高优业务的发奖消息消费延迟。
图片
图片
直播按业务能级可以划分高优和低优两类业务,一般低优类业务因门槛较低所以奖励发放量比较大,如:用户观看直播获得抽奖券。而高优类业务往往奖励发放量比较少,如:用户消费电池参与活动获得奖励。不同的是这两类业务各自对发放时效的敏感度,低优业务对时效不敏感,可接受延迟发放;高优业务时效敏感,奖励延迟到账易导致用户体验降低,甚至带来客诉。
奖励系统提供了让业务自主选择快慢通道的能力,上游可根据业务是否容忍延迟来选择快慢通道,可满足不同场景下的业务需求。
2.幂等
一级消息处理器接到消息后首先获取奖励包裹子奖励,如果这一步出现异常,会直接返回消息重试信号,等待消费者重试。
接着将包裹中包含的所有奖励进行封装,再按 uid%64 进行分库写表,这里会通过DB组合唯一键 souce + msgId + awardTypeId + awardId + uid 实现发奖幂等判断,如果已经存在发奖记录则跳过本次写入,之后再根据快慢队列各自的发奖链路进行二级队列转投,防止因某个下游发奖处理过慢导致整体消费吞吐量下滑造成的消息积压。
慢通道消息会按照奖品类型进行队列分配,一种奖品类型分到一组redis list队列,具体队列key的数量由初始化配置决定,而快队列则是选择databus,直接分成三个不同等级队列,这么选择的原因主要有以下两点:
- 快通道对奖励可达性和时效性要求较高,因此选用高可用的MQ作为承载方式,但使用MQ需要单独申请producer和consumer,这在运维部署上面增加了成本,因此只申请了高、中、低三个二级队列,避免了每次新增奖品类型都需要单独部署资源,针对不同的奖品类型会按照配置进行队列分配,比如涉及金钱的奖品类型会放入Fast队列,优先保障;一些已知的慢下游会主动配置放到Slow队列,避免拖慢整个发放队列,其他的奖品类型默认进入Default队列;
- 慢通道对奖励的时效性要求较低,redis list 不会增加运维负担,对于应用来说只是从逻辑上划分了多个队列,非常便于扩充新奖品类型,但redis的可用性比起MQ稍显不足,一旦出现意外情况可能会丢失数据,因此需要有离线补偿的手段,但这会牺牲奖励发放的时效性,因此仅针对慢通道的奖励采用这种模式。
3.可重试
二级队列消费者随时监听二级队列消息,收到发奖消息后根据不同的奖品类型进行下游发放交互,拿到结果后更新DB状态(如果失败则会累计重试发放次数),更新产生的binlog会被canal监听,如果遇到发放异常需要重试的情况会将消息写入三级队列,此队列主要用于奖励重试,基于 redis zset 结构设计的延迟队列进行指数退避重试发奖,按照 2^0s → 2^1s → 2^2s → ... → 2^12s,自动重试13次,大概在2小时左右,如果还未恢复,则判定为下游不可用,不再重试发奖等待人工介入。
图片
4.离线补偿
只要发奖记录落表成功,无论如何都需要走到最终的发奖,因此我们专门为此设计了离线补偿方案,通过job定时捞取离线库未发放完成的状态记录进行重试发放,如此可保证在最坏情况下的兜底,最大程度保证奖励的可达性。
5.业务接入
为了降低上游接入成本,奖励中心设计了一套易于理解且接入简单的消息格式,同时支持面向未来扩展,消息格式如下:
MQ消息体
{
"source":"(int64)业务来源,由奖励中心颁发",
"msg_id":"(string)消息id,同一个投递来源下的消息id需要保证唯一",
"uids":"([]int64)发奖励的目标uid,支持单条消息给多人发奖",
"package_id":"(string)奖励包裹ID,内部封装了多个子奖励",
"msg_time":"(int64)业务方时间戳,默认不传用下游接收到的时间处理",
"extra_data":"(string)扩展字段,json格式,用于面向扩展",
"business_type":"(string)业务类型,统计使用",
"business_id":"(string)业务ID,统计使用",
"expire_time":"(int64)动态过期时间戳,此时间会覆盖package内配置的过期时间"
}
extra_data 格式:
{
"lottery":{
"count":"(int64)发放包裹数量,在已有包裹奖励配置的数量上再*count得到最终发放数量"
},
"send_gold_seeds_package":{
"room_id":"(int64)送出的直播间id",
"ruid":"(int64)送出的主播ID"
}
}
以上消息基本满足语义:给xx用户发放xx奖励,易于理解,消息体内已经约定了幂等键,按照 source + msg_id 组成联合幂等键,防止超发,此外还支持了 extra_data 用于针对一些特定场景,比如动态数量、动态直播间等场景,如果未来有扩展需求可基于此持续迭代。
6.奖励配置
上面的发放条件内包含package_id,代指奖励包裹,其内部封装了我们发奖所需要的所有奖品参数,通过可视化面板进行奖品配置:
图片
奖品配置主要分为4个部分:
- 包裹ID :通过编辑不同的编号产生不一样的包裹ID,如果想把多个奖品放入同一个包裹可以给它们设置相同的编号;
- 基本属性:选择奖励类型,再根据类型对应的属性分别配置奖励ID、数量、有效期,不同的类型属性不同,取决于服务端下发;
- 扩展属性:不同的奖品类型附带的发奖参数也不相同,扩展属性根据奖励类型进行附加,以便在发放时作为参数携带;
- 显示配置:用于C端展示;
针对上述结构,我们抽象出奖励的基本结构体如下:
// AwardType 奖品类型
type AwardType struct {
// 类型ID
TypeId int64 `json:"type_id"`
// 类型名
TypeName string `json:"type_name"`
// 对C端展示类型名(为空则展示 TypeName)
ShowTypeName string `json:"show_type_name"`
// 类型值(用于标记扩展字段)
TypeVal string `json:"type_val"`
// 奖励ID控件类型 none:无 select:下拉 input:输入
AwardIdControl string `json:"award_id_control"`
// 奖品是否存在数量配置 0:否 1:是
IsNum int64 `json:"is_num"`
// 是否存在有效期配置 0:否 1:是
IsTime int64 `json:"is_time"`
// 有效期配置
TimeConfigData []*AwardTypeTime `json:"time_config_data"`
// 扩展属性控件配置
ExtendPropControl []*ExtendPropControlItem `json:"extend_prop_control"`
}
// SelectItem 扩展属性下拉数据选项
type SelectItem struct {
Id string `json:"id"`
Name string `json:"name"`
}
// ExtendPropControlItem 扩展属性
type ExtendPropControlItem struct {
// 属性名
PropName string `json:"prop_name"`
// 显示表头名
ColumnName string `json:"column_name"`
// 控件类型 switch、input、select
PropControl string `json:"prop_control"`
// 控件下拉数据,仅限控件类型为 select 会用到
PropSelectData []*SelectItem `json:"prop_select_data"`
}
// AwardTypeTime 奖品有效期配置
type AwardTypeTime struct {
// 有效期类型id
TimeType int64 `json:"time_type"`
// 有效期类型说明
TimeTypeDesc string `json:"time_type_desc"`
// 有效期值控件 (none:无 datepicker:日期选择框 input:文本框)
TimeValControl string `json:"time_val_control"`
// 有效期单位控件 (none:无 select:下拉框)
TimeUnitControl string `json:"time_unit_control"`
// 有效期单位数据
TimeUnitData []*SelectItem `json:"time_unit_data"`
}
服务端可以通过接口告知配置后台应该如何渲染配置面板,从而在新增奖品类型的时候无需后台介入开发,提升生产效率。
7.新奖品类型接入
作为一个通用的奖励系统,快捷高效地接入新的奖品类型应该放在首要位置考虑,系统设计了标准的奖励发放和回收接口,用于实现快速接入新类型能力,如图所示:
图片
AwardTypeFunc为奖励接口,AwardTypeDefaultFunc 为默认实现,下面继承自AwardTypeDefaultFunc的各个struct为具体奖励,在这里真正实现下游接口的调用。
奖励发放接口拿到上游奖励包裹配置数据和发放消息参数后,先进行参数验证,计算奖励的过期时间,调用下游接口进行奖励发放,由于下游接口实现标准各不统一,如不支持批量用户发放,还需要奖励自身做一层适配转换,最终给到上游结果,结果返回发放成功的用户ID、发放失败的用户ID、需要重试的用户ID以及失败的具体错误明细,上游再根据返回值做对应的流程操作。
8.数据监控
系统目前做了奖励发放记录相关的埋点,可以观测到一段时间范围内各个奖品类型的发放情况,通过监控可分析出单品斜率过高的奖励,从而针对治理(提速、隔离、限速)。
图片
未来规划
奖励系统经过多次迭代逐步演化成如今的多层次架构,未来还需要在以下几个方向分别进行建设:
- 奖励配置复杂度过高导致配置准确性无法保障,需要接入配置自检能力,尽早检测出配置问题,避免线上事故;
- 系统的监控粒度覆盖不够全面,如:奖励发放异常监控、上游发放来源监控、各个队列的生产消费情况监控;
- 测试环境自动化回归。