雪球统一推送平台搭建实践:高吞吐、低延迟、提升用户满意度和产品体验

前言

需求背景

雪球近几年来用户量和产品线激增。为了更加贴切的迎合公司业务发展和用户个性化需求,实现以下目标:

  • 满足用户对信息把控的时效性
  • 增加用户终端机型的覆盖率
  • 提升用户满意度和产品体验

雪球统一推送平台应运而生,推送作为 APP 运营中一个关键渠道,通过对它的合理运用,可以很好的促进目标实现。目前已在:关注发帖、回复评论、股价提醒、个股公告、组合调仓等多个业务场景服务用户,同时帮助运营人员将7*24小时资讯、行情速递等精选内容第一时间投递到目标用户。

产品设计

雪球早期自建推送是基于自建长连接和第三方依赖,前期由于历史业务冗余和代码可维护性差,存在以下问题:

问题 原因
缺乏ACK机制 推送是异步的无法得知是否送达,第三方受其他推送方的影响,可能造成延迟和丢失
缺乏消息的持久化 消息来一条推一条无法追溯历史和状态
缺乏幂等重传机制 推送环节过多,任何环节出问题都会造成消息丢失,再无法收到
客户端接入逻辑复杂 每接入一个新的APP都需要进行重复工作,代码无法复用
客户端与推送服务的 SDK 强耦合 推送端提供的接口不统一,如果需要替换Client就要重写
缺乏数据监控和统计 每天推送了多少,成功到达了多少,失败了多少

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

通道能力建设

Android通道

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

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

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

单日推送总量限制

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

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

解决方案:

  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小时后恢复)
VIVO 10072 推送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_id Push NC自动为给每条消息生成一个唯一标识;不同的通知栏消息可以拥有相同的notifyId,实现新的消息覆盖上一条消息功能。
OPPO app_message_id API推送请检查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();
复制代码

iOS & 其他通道

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

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

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

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

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

平台能力建设

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

系统能力

推送平台目前由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,通过小米后台查询就可知道厂商下发链路状态

数据能力

完成消息推送的下一步是进一步地对不同业务、场景进行闭环管理和效果跟踪,通过数据大盘量化推送效果。数据大盘目前已经涵盖三个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,数据可以用作参考)、推送内容热度标签、厂商通道送达率指标优化,优化推送业务对用户的体验等。

业务能力

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

运营侧

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

审核侧

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

回顾总结

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

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

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

问题 实现
缺乏ACK机制 利用HTTP接口调用的 callback 返回结果,实时反馈厂商通道ACK状态
缺乏消息的持久化 对每条消息采用 msg_id + uid 机制,通过数据能力搭建消息追踪截机制
缺乏重传机制 直接采用厂商提供的幂等参数,做到异常消息重传下发
客户端接入逻辑复杂 配合前端基础设施,沉淀基础能力和组件做到复用与快速接入
客户端与推送服务的 SDK 强耦合 规范所有厂商接口的数据埋点字段,轻量化前端代码同时达到标准化数据流程
缺乏数据监控和统计 丰富系统整他的监控和链路追踪,同时将数据和功能代码拆分便于量化指标

未来展望

智能频控免打扰设计

提升平台整体资源的利用效率,降低用户不必要的打扰,将资源给到用户最关心部分。

站内站外推送同步设计

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

SMS和PUSH互补下发设计

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

参考链接

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


贡献团队

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

招聘信息

雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。

猜你喜欢

转载自juejin.im/post/7047412569060933663
今日推荐