雪球推送平台建设之路

avatar
@雪球财经

来自雪球社区平台/基础组件

一、前言

1.1 推送平台是什么

雪球用科技实现了人与股票、资讯、内容、金融产品的连接。雪球社区每天产生大量优质内容,所属行业每天有大量热点事件,社区用户各自订阅了感兴趣的消息。「雪球统一推送平台」服务于以上业务场景,为社区和用户之间提供了一座桥梁:广泛覆盖、及时触达、精准投递。

1.2 推送平台解决的问题

雪球早期推送能力存在以下问题:

缺乏ACK机制推送是异步的无法得知是否送达,第三方受其他推送方的影响,可能造成延迟和丢失
缺乏消息的持久化消息按单条处理未能持久化所有状态
缺乏幂等重传机制推送调用链过长,任何环节出问题都会造成消息丢失
缺乏统一管理配置、权限、配额、频控未设计成统一管理,分散在各业务系统
客户端与推送服务的 SDK 强耦合推送端提供的接口不统一,如果需要替换Client就要重写
缺乏数据监控和统计埋点过少,未能形成统计分析的基础
缺乏动态推送策略无法动态感知用户喜好,可能对用户造成一定打扰,未合理使用平台资源

基于以上问题,通过调研比对业内实现方案,立足于雪球现状设计实现了基于各大主流厂商的自建推送通道,从根本上解决推送场景面临的实际困难。

二、推送平台核心建设与设计

2.1 通道能力建设

手机厂商众多,推送服务不可避免需要面临碎片化问题,目前雪球推送已集成苹果、华为、小米、OPPO、VIVO、魅族原生手机厂商通道,其余设备接入依托第三方友盟通道。

2.1.1 Android通道

在推送内容审核、额度限制和流量控制多方面,各大手机厂商都有自己不同的平台规则。面对这些共性问题,从平台搭建至今一直跟进由中国信通院推动的统一推送联盟,目前来看想要结合雪球当前情况实施落地还不是合适的时机。

以下是雪球推送平台的优化方案,其中未提及部分为当前阶段尚未触达或未发现类似问题。

  • 优化单日推送总量

手机厂商的推送总数限制,如下表:

通道状态码官方简述
小米200001推送数量超过当日限制时,会调用请求失败,返回错误码200001
OPPO33消息条数超过日限额,接口返回:The number of messages exceeds the daily limit
VIVO10070可发送的单推和群推消息指定的用户量不得超过每日限制的推送总量

解决方案:

  1. 优化业务内容推送逻辑,对各厂商通道内容下发制定不同策略,保障关键内容下发
  2. 根据APP应用类型和厂商的规则提交申请,可以增加不同的推送额度

根据消息下发标识出归属的业务种类,区分优先级来保障关键内容下发

message Message {

    int64 messageId = 1;

    string title = 2; // 推送消息标题

    string payload = 3; // 推送内容主体

    string description = 4; // 通知栏上方描述(摘要)

    string callback = 5; // "eg:http://example.com" 回调地址

    string summary_callback = 6; // "eg:http://img.com" 通知图片地址 

    Type type = 7; // 业务类型,根据业务类型划分下发优先级

    Application app = 8; // 推送的客户端,由同一平台下发多端APP

    repeated int64 target = 9; // 推送目标用户(详细推送用户,数组格式)

    int64 created = 10; // 消息创建时间

    int32 ttl = 11; // 消息的过期时间(单位ms)

    map<string, string> ext = 12; // 其他自定义字段

    map<string, string> version_filter = 13; // 版本过滤,漏斗模式

    Application targetType = 14; // 推送目标用户的id类型

}
  • 降低推送速率限制

雪球作为一个财富管理类应用,其中交易、行情和内容资讯一直为用户关心的首要内容。对于推送的实时性要求更高,推送服务面临数据量和QPS等众多问题,其中流控限制如下表:

通道状态码官方简述
小米200002小米推送对推送速率(QPS)的分配主要依据App的MIUI日联网设备数进行分级计算,QPS超限时会返回错误码200002
华为HTTP-503推送次数限制:每天向某个设备上某个应用发送消息数量不超过3000条,超过3000条进行限流(限流24小时后恢复)
VIVO10072推送QPS根据SDK订阅数自动调整,默认值为3000条/秒

解决方案:

  1. 类似限额解决思路,优化下发逻辑,保障关键内容下发
  2. 充分利用各大厂商提供的批量下发接口
  • 小米和华为限制的QPS为接口访问频次,因此在数据到达厂商通道前,根据用户发送渠道提前聚合,尽可能多的使用批量发送。

根据小米官方描述:1个请求中最多可以携带1000个目标设备。例如:3000QPS时,1秒内最多可推送 300万设备。最高可以实现 300w/秒的下发。

  • OPPO和VIVO的批量下发接口和单条下发接口有不同的访问频次限制,在进行数据下发时,根据消息内容标识,当批量下发接口触发上限后,切换到单条下发接口。
  1. 制定消息有效时间,通道层在触发厂商QPS上限后,再次进入推送下发队列
//小米推送通道触发流控限制,根据状态码判断进行回传重试

...

String responseBody = URLDecoder.decode(response.body().string(), "UTF-8");

JsonNode obj = MAPPER.readTree(responseBody);

...

if ("200002".equals(obj.get("code").asText())) {

    // 200002限速,稍后重试

    limitCounter.increment();

    LOGGER.warn("小米api接口调用触发频控限制,重传的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList, responseBody, message.getApp().name(), message.getMessageId());

    pushStatusProducer.sendMessageRetry(message.toBuilder().clearTarget().addAllTarget(uidList).build());

    return;

}

此方案需注意:

  • 需要有消息的 deadline,否则最后下发成功,内容的时效性在用户体验上也会打折扣
  • 消息重传需要考虑幂等性,在弱网和其他边界情况下重传会导致推送重复,影响用户体验,对于消息幂等各大厂商给出的解决方案如下表:
通道幂等参数描述
小米notify_id如果通知栏要显示多条推送消息,需要针对不同的消息设置不同的notify_id(相同notify_id的通知栏消息会覆盖之前的),且要求notify_id为取值在0~2147483647的整数
华为notify_idPush NC自动为给每条消息生成一个唯一标识;不同的通知栏消息可以拥有相同的notifyId,实现新的消息覆盖上一条消息功能。
OPPOapp_message_idAPI推送请检查app_message_id是否自定义,API单推相同的app_message_id只推送一次。

上述厂商给出的解决方案除了表格中的,其实还有相似的其他手段解决。例如:小米的 'extra.jobkey' 字段或华为的 'group' 字段可以实现消息折叠,也可以改善用户体验上相关问题。

//小米API接口请求体封装,利用notify_id参数保障消息幂等下发

RequestBody requestBody = new FormBody.Builder()

        .add("payload", MAPPER.valueToTree(messageTemplate).toString())

        .add("restricted_package_name", packageName)

        .add("description", (messageTemplate.getDescription().length() > 120 ? messageTemplate.getDescription().substring(0, 120) + CutString.SUB_TAIL: messageTemplate.getDescription()))

        .add("extra.notification_large_icon_uri", StringUtils.trimToEmpty(message.getSummaryCallback()))

        .add("title", messageTemplate.getTitle().length() > 50 ? messageTemplate.getTitle().substring(0, 50) : messageTemplate.getTitle())

        .add("pass_through", "0")

        .add("notify_type", "-1")

        // 开发者在发送消息时可以设置消息的组ID(JobKey), 带有相同的组ID的消息会被聚合为一个消息组

        .add("extra.jobkey", String.valueOf(messageTemplate.getMessageId() & Integer.MAX_VALUE))

        .add("registration_id", StringUtils.join(deviceTokens, ","))

        //默认情况下, 通知栏只显示一条推送消息, 如果通知栏要显示多条推送消息, 需要针对不同的消息设置不同的notify_id

        .add("notify_id", String.valueOf(messageTemplate.getMessageId() & Integer.MAX_VALUE))

        .build();

2.1.2 IOS & 其他通道

苹果厂商的通道下发根据官方提供的APNs实现,早期是采用了基于JDK实现,由于性能较差目前采用了开源的第三方SDK:pushy

使用过程中偶尔也有问题,但大部分是网路链路环境原因。通过调研得到个方案:将 iOS 推送任务所在服务节点就近部署至APNs服务器。但是基于实际使用现状及目前 iOS 业务需求,在此只作讨论。

魅族通道根据官方API文档接入,可以满足当前的QPS和总量使用在此不做过多讲述。

友盟通道或极光等其他第三方通道在上述的各大厂商通道接入的前提下可以优化通道的两方面能力:

  1. 其他手机用户的接入,提高推送下发的覆盖率
  2. 在系统的构建中承担一个 fallback 的角色,保障系统的健壮性

2.2 平台能力建设

目前统一推送平台在提供通道能力基础上,更加丰富平台的系统、数据和业务能力

2.2.1 系统能力

推送平台目前由8台4vCPU 8GiB服务器:实现80+w/s的消息总数下发、满足10+亿/天的业务指标当前性能瓶颈全在厂商侧的限制)。如何保障系统自身的高可用和稳定性,除了良好的初期架构设计,还需要对系统进行持久的优化迭代和跟踪指标体系便于提前告警和分析问题。

推送通道优化下发期间面临众多问题,贴出两个代表型的问题在此简述下:

  • 厂商通道调用选型

通道下发的选型最初采用各厂商提供的SDK进行集成,其中大部分的包与公司基础架构设施依赖冲突,在性能优化和业务兼容中也存在众多问题。例如:日志组件冲突、SDK线程池调整和版本升级兼容困难、HTTP接口数据返回内容不全等。因此最终选用API接口进行封装,多通道报文协议自行解析,统一推送通道连接标准。

基于以上原因利用消息总线和OkHttp的异步请求,数据格式、代码模型和性能目标统一。

//call_before,OkHttp下发前统一格式封装

public static RequestBody requestBodyFormat(MessageProto.Message message, String packageName, List<String> deviceTokens, boolean channelSwitch) throws UnsupportedEncodingException {

    MessageTemplate messageTemplate = MessageTemplate.messageConvert(message, MessageProto.Platform.XIAOMI);

    messageTemplate.setTitle(StringUtils.isEmpty(messageTemplate.getTitle()) ? PushTitleUtils.getTitleFromAPP(message.getApp()) : messageTemplate.getTitle());

    RequestBody requestBody = new FormBody.Builder()

    .add("payload", MAPPER.valueToTree(messageTemplate).toString())

    .add("restricted_package_name", packageName)

    .add("description", (messageTemplate.getDescription().length() > 120 ? messageTemplate.getDescription().substring(0, 120) + CutString.SUB_TAIL: messageTemplate.getDescription()))

    .add("extra.notification_large_icon_uri", StringUtils.trimToEmpty(message.getSummaryCallback()))

    .add("title", messageTemplate.getTitle().length() > 50 ? messageTemplate.getTitle().substring(0, 50) : messageTemplate.getTitle())

    .add("pass_through", "0")

    .add("notify_type", "-1")

    // 开发者在发送消息时可以设置消息的组ID(JobKey), 带有相同的组ID的消息会被聚合为一个消息组

    .add("extra.jobkey", String.valueOf(messageTemplate.getMessageId() & Integer.MAX_VALUE))

    //使用批量接口下发,单次最大1000个deviceToken充分利用批量机制提高系统吞吐率

    .add("registration_id", StringUtils.join(deviceTokens, ","))

    //默认情况下, 通知栏只显示一条推送消息, 如果通知栏要显示多条推送消息, 需要针对不同的消息设置不同的notify_id

    .add("notify_id", String.valueOf(messageTemplate.getMessageId() & Integer.MAX_VALUE))

    .build();

    return requestBody;

}

//call,OkHttp进行通道消息下发

public void send(List<UserStateProto.Device> deviceList, MessageProto.Message message, RequestBody requestBody) {

    List<Long> uidList_GE = deviceList.stream().map(m -> m.getUid()).collect(Collectors.toList());

    try {

        LOGGER.info("小米api接口调用前,将要发送的用户uid列表:{} | 发送的消息报文:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList_GE, OkHttp3ConvertUtils.requestBodyURLToString(requestBody), message.getApp().name(), message.getMessageId());

        Request request = new Request.Builder()

                .url(xiaomiSendUrl)

                .addHeader("Authorization", String.format("key=%s", accessToken))

                .post(requestBody)

                .build();

        Call call = okHttpClient.newCall(request);

        call.enqueue(new XiaomiResponseCall(deviceList, message, pushStatusProducer));

    } catch (Exception e) {

        exceptionCounter.increment(deviceList.size());

        LOGGER.error("小米api接口调用过程异常,失败的用户uid列表:{} | 失败的原因:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList_GE, e.getMessage(), message.getApp().name(), message.getMessageId(), e);

        pushStatusProducer.sendByDeviceList(PushResultEnum.FAIL, PushFailedTypeEnum.SYSTEM_ERROR, e.getMessage(), deviceList, message);

    }

}

//call_back,OkHttp异步结果回调

public void onResponse(Call call, Response response) throws IOException {

    String responseBody = URLDecoder.decode(response.body().string(), "UTF-8");

    if (response.isSuccessful()) {

        JsonNode obj = MAPPER.readTree(responseBody);

        if ("0".equals(obj.get("code").asText())) {

            JsonNode jsonNode = obj.findPath("data").findPath("bad_regids");

            if (jsonNode.isMissingNode()) {

                successCounter.increment(deviceList.size());

                LOGGER.info("小米api接口调用返回全部成功,成功的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList, responseBody, message.getApp().name(), message.getMessageId());

                pushStatusProducer.sendByDeviceList(PushResultEnum.SUCCESS, PushFailedTypeEnum.NULL, "SUCCESS", deviceList, message);

            } else {

                List<String> failedTokenList = new ArrayList<>();

                for (String objNode : jsonNode.textValue().split(",")) {

                    failedTokenList.add(objNode);

                }

                List<UserStateProto.Device> failedList = deviceList.stream().filter(f -> failedTokenList.contains(f.getDeviceToken())).collect(Collectors.toList());

                failedCounter.increment(failedList.size());

                LOGGER.info("小米api接口调用返回部分失败,失败的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", failedList.stream().map(m -> m.getUid()).collect(Collectors.toList()), responseBody, message.getApp().name(), message.getMessageId());

                pushStatusProducer.sendByDeviceList(PushResultEnum.IGNORE, PushFailedTypeEnum.CHANNEL_ERROR, responseBody, failedList, message);



                List<UserStateProto.Device> successedList = deviceList.stream().filter(f -> !failedTokenList.contains(f.getDeviceToken())).collect(Collectors.toList());

                successCounter.increment(successedList.size());

                LOGGER.info("小米api接口调用返回部分成功,成功的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", successedList.stream().map(m -> m.getUid()).collect(Collectors.toList()), responseBody, message.getApp().name(), message.getMessageId());

                pushStatusProducer.sendByDeviceList(PushResultEnum.SUCCESS, PushFailedTypeEnum.NULL, "SUCCESS", successedList, message);

            }

        } else if ("200002".equals(obj.get("code").asText())) {

            // 200002限速,稍后重试

            limitCounter.increment();

            LOGGER.warn("小米api接口调用触发频控限制,重传的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList, responseBody, message.getApp().name(), message.getMessageId());

            pushStatusProducer.sendMessageRetry(message.toBuilder().clearTarget().addAllTarget(uidList).build());

            return;

        } else {

            failedCounter.increment(deviceList.size());

            LOGGER.warn("小米api接口调用返回全部失败,失败的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList, responseBody, message.getApp().name(), message.getMessageId());

            pushStatusProducer.sendByDeviceList(PushResultEnum.IGNORE, PushFailedTypeEnum.CHANNEL_ERROR, responseBody, deviceList, message);

        }

    } else {

        failedCounter.increment(deviceList.size());

        LOGGER.error("小米api接口调用返回异常,失败的用户uid列表:{} | 返回的消息体:{} | 推送的APP:{} | 该批消息的messageId:{}", uidList, responseBody, message.getApp().name(), message.getMessageId());

        pushStatusProducer.sendByDeviceList(PushResultEnum.IGNORE, PushFailedTypeEnum.CHANNEL_ERROR, responseBody, deviceList, message);

    }

}
  • 推送消息全链跟踪

由于离线推送不是由自建长连接通道下发,如何定位每个用户的每条推送消息当前状态是个不可忽视的问题。各厂商推送后台都集成的有对应的问题Debug工具,因此在推送平台数据埋点中API接口的返回数据需要记录厂商对应的 trace_id,以便问题定位和数据分析。

例如:小米厂商需要IMEI和接口返回的批次ID,通过小米后台查询就可知道厂商下发链路状态

2.2.2 数据能力

完成消息推送的下一步是进一步地对不同业务、场景进行闭环管理和效果跟踪,通过数据大盘量化推送效果。数据大盘目前已经涵盖三个APP的几十种业务场景,提供实时数据和离线数据分析。

在数据能力建设时,架构上直接将系统链路上所有的数据层通过消息总线的方式传输。细化每条消息的报文格式,规定由 msg_id + uid 作为唯一标识,应用端统一采用 event_tracking 作为推送平台埋点字段,实现了数据指标体系的规范和接入标准。

//消息总线实时推送数据格式规范

public void sendByDevice(PushResultEnum pushResultEnum, PushFailedTypeEnum pushFailedTypeEnum, String reason, UserStateProto.Device device, MessageProto.Message message) {

    MessageAck messageAck = new MessageAck();

    messageAck.setUploadTime(System.currentTimeMillis());

    messageAck.setMsgId(message.getMessageId());

    messageAck.setUid(device.getUid());

    messageAck.setChannel(device.getDeviceChannel());

    messageAck.setResult(pushResultEnum.getTypeName());

    messageAck.setFailedType(pushFailedTypeEnum.getTypeName());

    messageAck.setFailedReason(reason);

    messageAck.setAppVersion(device.getAppVersion());

    messageAck.setToken(device.getDeviceToken());

    messageAck.setDescription(message.getDescription());

    messageAck.setApp(message.getApp().name());

    messageAck.setBizType(message.getExtMap().get(TrackingExtKey.BIZ_TYPE));

    //扩展字段K/V,应对临时变更性需求

    messageAck.setExt(message.getExtMap());

    messageAck.setCallback(message.getCallback());

    sendMessageACK(messageAck);

}

依托推送数据能力可以做到:APP卸载率分析(依赖于厂商推送token,数据可以用作参考)、推送内容热度标签、厂商通道送达率指标优化,优化推送业务对用户的体验等。

2.2.3 业务能力

推送运营中台除了基础推送下发功能,还为运营提供了推送效果分析:对每一条推送消息记录推送各阶段明细数据,形成漏斗分析。运营人员通过运营中台了解一条消息的生命周期,量化推送效果,优化后续选题和人群。

  • 运营侧

运营决策千变万化,除了定时任务下发之类的基础功能,推动平台在架构设计上对功能层面和数据层面均做了隔离,方便配合大数据和算法实现动态的目标圈选和算法个性化千人千面。

  • 审核侧

厂商对推送内容有各自严苛的标准,国内运营的监管环境同时对用户数据有严格管理,推送平台在平台搭建中模块化数据流转处理,以满足审核内容动态调整。

三、回顾总结

以上主要是分享在推送平台搭建和优化过程中面临和解决的一些问题,重点是架构技术选型和厂商通道优化,主要是以下两点:

  • 架构上尽量将业务功能和数据体系解耦合,可以使用消息总线的方式将业务逻辑和数据分析分开
  • 通道下发在选型上统一使用API接口进行交互,方便后续维护、性能优化和业务个性化需求接入

基于以上方案和技巧对于文章开始的问题都通过如下方式得到解决:

缺乏ACK机制利用HTTP接口调用的 callback 返回结果,实时反馈厂商通道ACK状态
缺乏消息的持久化对每条消息采用 msg_id + uid 机制,通过数据能力搭建消息追踪截机制
缺乏重传机制直接采用厂商提供的幂等参数,做到异常消息重传下发
缺乏统一管理配置、权限、配额、频控统一管理、业务方快速接入
客户端与推送服务的 SDK 强耦合规范所有厂商接口的数据埋点字段,轻量化前端代码同时达到标准化数据流程
缺乏数据监控和统计丰富系统整他的监控和链路追踪,同时将数据和功能代码拆分便于量化指标
缺乏动态推送策略分析用户喜好和关注度,动态调整推送频次降低对用户不必要打扰

四、未来展望

4.1 站内站外推送同步设计

配合站内瀑布提醒,做到厂商离线与长连接在线推送组合下发,降低推送平台压力。

4.2 SMS和PUSH互补下发设计

配合短信提醒,提高关键类信息的到达率,提升用户产品体验。

4.3 服务弹性

利用基础设施的可伸缩性、服务无状态、小批量发送的设计,应对极端业务场景

参考链接

APNs / MiPush / HMS / Opush / Vpush / meizu push