做Java服务端集成推送,我见过太多人把精力全花在客户端SDK上,结果服务端对接的时候反而被一堆琐碎问题卡住:registrationId 到底从哪拿、推送凭据怎么配、为什么消息发出去了手机却没响、为什么偶尔超时、为什么生产环境推不动。这篇就围绕集成极光推送(JPush)和 MobPush 这两条线,把服务端集成的完整链路、关键参数、限流策略和排查思路一次性讲清楚,适合正在接推送、或者准备从零开始搭推送服务的后端开发同学参考。先说一个我反复强调的观点:服务端集成推送,难点从来不在“调通接口”,而在“搞清楚推送的完整链路”。你只有知道一条消息从你的业务服务器出发,经过推送平台、厂商通道、系统通知栏,最终到达用户手机,这条路上每一站都有什么规则和坑,你才能设计出可靠的推送模块。这篇我也尽量不写“官方文档翻译稿”,只写实战里验证过的东西。1. 推送集成第一步:搞懂端到端链路,别一上来就写代码1.1 一条推送消息从服务端到用户手机的完整通路我们以极光推送为例,把一次推送拆成四段:第一段是业务服务端到极光服务器。你的Java后端通过 HTTPS 调用极光 REST API,带上 AppKey 和 Master Secret 鉴权,把推送内容、目标人群(registrationId 列表、标签、别名或广播)、过期时间等参数交给极光。第二段是极光服务器到客户端的长连接。客户端App嵌入了极光SDK,SDK会和极光服务器维持一条长连接,用于实时接收下发指令。如果App在前台或后台运行,长连接存活,消息秒到。第三段是厂商通道兜底。当App被用户杀掉,或者系统休眠导致长连接断开,极光会尝试通过设备所属厂商的系统推送通道(比如小米、华为、OPPO、vivo、荣耀各自的推送服务)来下发。这就是移动推送领域常说的“厂商通道”。第四段是系统通知栏展示。无论消息走的是长连接还是厂商通道,最终都要由手机系统把通知展示出来,并触发你预先写好的点击回调逻辑。这段链路里最容易出问题的就是第三段:第三方推送平台能保证自己服务器到App长连接这条路的可靠性,但“长连接断开后转入厂商通道”这件事,既有设备厂商的策略限制,也有你的App有没有正确集成厂商推送SDK的问题。很多人以为只要服务端调了接口,消息就一定到达,这就是最大的误解。1.2 Java服务端在整条链路里到底承担了什么角色搞清楚链路之后,你就能定位Java服务端的职责边界了:它只负责把“推送请求”可靠地交给推送平台,并处理推送平台的响应、回执和回调。客户端能不能收到、什么时候收到、走哪条通道,这些主要由客户端SDK集成、厂商通道注册、用户手机系统状态决定。这个边界意识非常重要。我遇到过一个团队,服务端推送接口调用成功后,用户没收到消息,他们一遍遍查服务端日志、调参数,折腾了一整天,最后发现是客户端在部分低版本Android机型上没能成功初始化厂商推送SDK,所以App被杀后就完全收不到离线消息。服务端代码改了几版都白搭。所以这篇里的所有操作,都默认遵循一个原则:服务端做到按规则正确送达、记录投递状态、合理重试,不越权替客户端承担本应由SDK负责的通道注册工作。如果你连客户端SDK是否初始化成功都无法确认,先别急着优化服务端。2. 账号体系与基础配置:环境隔离比想象中更重要2.1 极光应用基本信息:AppKey、Master Secret 和推送业务的关系登录极光控制台,创建应用后你会拿到AppKey和Master Secret。AppKey 是应用的身份标识,相当于“用户名”,在客户端SDK初始化和服务端API调用中都会用到;Master Secret 是服务端鉴权密钥,相当于“密码”,只允许保存在服务端,绝对不能下发到客户端。在REST API调用里,这两个值以 Basic Auth 的形式放在请求头中。极光要求请求头带上Authorization: Basic (base64(appkey:masterSecret))。写Java代码的时候,很多人会手动拼接base64,其实细看官方示例,更多是直接构造一个 BasicAuth 的 header 值。这里有一个很容易被忽略的点:AppKey 和 Master Secret 是配套的,如果你在控制台里重置了 Master Secret,服务端所有旧请求会立即失效,同时其他环境里的同名密钥也会跟着变。所以重置密钥前,先确保所有环境都已更新。2.2 环境隔离:开发、测试、生产要用不同的应用极光的账号体系里,一个极光账号下可以创建多个应用。很多团队只用同一个极光应用同时跑测试和线上,结果就会出现:测试机收到线上推送、线上用户收到测试环境的垃圾推送、调试时参数改来改去互相干扰。我的建议是至少拆三个应用:Dev应用:开发联调用,包名写开发包名,谁都别往里面发正式内容。Test应用:测试环境用,可以做自动化测试的推送验证。Prod应用:生产环境专用,Master Secret 只在配置中心或密钥管理系统中保存。每个应用拥有独立的 AppKey 和 Master Secret,客户端也通过不同的初始化参数指向不同环境。这样排查问题的时候,你可以直接根据AppKey确定问题归属,而不是在一堆混合日志里大海捞针。如果你们的客户端有多个渠道包(比如应用市场渠道、运营定制包),每个渠道包可能也要对应不同的包名或推送配置。这里我建议渠道包的推送配置尽量统一,否则服务端要同时管理多套 AppKey,投递统计还会被切碎,运营数据很难看。2.3 密钥管理:别把 Master Secret 写进代码仓库Master Secret 泄露意味着任何人只要拿到它,就能用你的应用名义向全量用户发推送。这不是危言耸听,极光的API设计里,服务端是可以通过 Master Secret 发广播的。所以我强烈建议:配置放到环境变量、配置中心或KMS中,不要硬编码在源码里。配置文件即使要做示例,也一律用占位符代替。定期轮换密钥,换完后立刻观察服务端日志确认没有401。我见过有人为了省事,把 Master Secret 直接写在项目的 application.yml 里,还提交到了Git仓库。后来仓库公开,很快就被别人盯上,给全量用户发了一堆垃圾推送。这种事故一旦发生,轻则卸载率飙升,重则影响应用在渠道的评级。3. 极光推送服务端集成实操:从鉴权到第一个推送请求3.1 选型:直接用REST API还是官方Java SDK极光官方提供 Java SDK,内部封装了鉴权、请求构造、响应解析等逻辑,使用上确实省事。但我个人的建议是:核心推送逻辑自己封装一层HTTP调用,不要只依赖SDK。原因有三个:第一,官方SDK升级节奏你不一定跟得上,如果某天你遇到一个诡异的bug,直接看SDK源码看不出来问题,而REST API的文档和排查资料更多。第二,推送请求本质上是普通HTTPS POST,你封装一次之后,超时、重试、日志、监控都可以按自己团队的标准来治理,而不是被SDK内部行为带着走。第三,很多场景下你们不只需要极光,还可能接MobPush或其它平台(比如厂商直连),自己封装一层统一的 PushProvider 接口,后面切换或扩展会非常容易。当然,如果你只是想快速跑通,直接用官方SDK没问题。下面我以REST API为主写完整示例,看完你也能轻松切换。3.2 推送目标:registrationId、alias、tag 怎么选推送目标决定一条消息发给谁。极光支持按registrationId(客户端设备ID)、alias(别名)、tag(标签)、segment(分群)以及广播来圈定受众。服务端开发最常见的目标是 registrationId 和 alias。registrationId 是客户端SDK成功初始化后由极光分配的一个设备标识,长这样一般是1503bfd6f42之类的字符串。业务服务端通常在用户登录或App启动时,把 registrationId 上报到自己库里,推送时再根据用户ID查出来。alias 更适合业务侧,比如你把用户ID设置为alias,那么可以直接sendToAlias。但要注意alias 的规则:同一应用下alias 全局唯一(至少极光设计上要求尽可能唯一),如果你在多个设备登录同一个账号,后设置的alias会覆盖之前的绑定关系,旧设备可能就收不到定向消息。我的经验是:上报 registrationId 到服务端,业务侧存一份“用户-设备”映射表,推送时先查表再发。alias可以做简化场景,但不要依赖它做精细化设备管理。因为业务系统往往有账号解绑、设备换绑、多设备登录这些复杂逻辑,直接用registrationId 更可控。3.3 核心实现:一封完整的推送请求长什么样我们用OkHttp构造一次推送请求。前提取Maven里引入:<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>极光REST API的推送端点是:POST https://api.jpush.cn/v3/push请求头:Authorization: Basic base64(AppKey:MasterSecret) Content-Type: application/json一个最简单的广播推送请求体:{ "audience": "all", "notification": { "android": { "alert": "你好,这是一条测试推送", "title": "测试标题", "extras": { "from": "order_system" } }, "ios": { "alert": "你好,这是一条测试推送", "sound": "default" } }, "options": { "apns_production": false } }Java代码对应如下:import okhttp3.*; import com.google.gson.JsonObject; import java.util.Base64; import java.util.concurrent.TimeUnit; public class JPushClient { private static final String PUSH_URL = "https://api.jpush.cn/v3/push"; private final OkHttpClient httpClient; private final String authHeader; public JPushClient(String appKey, String masterSecret) { String token = Base64.getEncoder() .encodeToString((appKey + ":" + masterSecret).getBytes()); this.authHeader = "Basic " + token; this.httpClient = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); } public String pushToDevice(String registrationId, String title, String alert, JsonObject extras) throws IOException { JsonObject payload = new JsonObject(); // 定向推送 JsonObject audience = new JsonObject(); audience.addProperty("registration_id", registrationId); payload.add("audience", audience); JsonObject android = new JsonObject(); android.addProperty("alert", alert); android.addProperty("title", title); if (extras != null) { android.add("extras", extras); } JsonObject notification = new JsonObject(); notification.add("android", android); payload.add("notification", notification); JsonObject options = new JsonObject(); options.addProperty("apns_production", false); // 测试环境 payload.add("options", options); Request request = new Request.Builder() .url(PUSH_URL) .post(RequestBody.create(payload.toString().getBytes(), MediaType.parse("application/json; charset=utf-8"))) .header("Authorization", authHeader) .build(); try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException("push failed, code=" + response.code() + ", body=" + response.body().string()); } return response.body().string(); } } }这段代码里有几个细节值得注意。第一,audience结构里写的是registration_id而非registrationId,下划线是API字段名,别用驼峰。这个问题我见过不下三次,请求体构造出来,服务端返回参数错误。第二,apns_production控制的是iOS端推送环境。开发证书填false,生产证书填true。这个参数一旦在生产环境配反了,iOS推送就会一直失败,而且失败日志只在极光后台能看到,客户端完全无感知。第三,Android 的notification里可以不写title,但建议写。很多机型通知栏对没有标题的通知展示效果不理想。3.4 推送响应的含义与幂等设计推送接口调用成功后,极光会返回类似这样的响应:{ "sendno": 140920140890, "msg_id": "2023122910300001", "statusCode": 0 }这里的msg_id是这次推送的唯一标识,后续查询送达统计、离线消息回执都需要它。sendno是调用方传的可选序号,主要用来做幂等。极光的文档里提到,为了防止重复推送,可以在请求参数中加入sendno。我的建议是:服务端每次生成推送请求时都带上一个业务侧唯一的sendno,比如雪花ID或业务单号,这样如果网络超时后你决定重试,平台能根据sendno判断是否已处理过,避免同一消息被重复下发。不过要提醒一下:理论上用了sendno能降低重复概率,但依赖它做严格幂等并不绝对可靠。真正要紧的业务通知(比如订单支付结果),还是要靠业务层再加一层逻辑保证——推送只是尽力而为的通知手段,不能把它当成强事务。3.5 超时和异常重试的一点体会推送接口是外部依赖,需要像对待数据库一样治理超时。OkHttp里我一般把 connectTimeout 设成10秒,read/write 30秒。如果出现SocketTimeoutException,说明极大可能是请求已经到达极光服务器,只是响应返回慢或网络抖动,这时直接重试同一个请求,大概率没问题。但要注意:不是所有异常都适合无脑重试。如果是4xx,比如401鉴权失败、400参数错误、403无权限,重试多少次都一样,这时候要优先查配置和参数,不要一直在业务代码里循环重试把日志打爆。如果是5xx或网络异常,可以按指数退避重试,最多两到三次,不要无限重试。4. 关键参数与投放策略:透传、通知栏和厂商通道的取舍4.1 notification、message、options 三者的关系在极光的REST API中,推送内容分为两个大类:notification和message。notification是通知栏消息,由系统直接展示,不经过你的业务代码,点击行为通过回调告知客户端。适合运营活动、站外通知等场景。message是自定义消息(也叫透传消息),SDK收到后不会自动展示,而是通过回调接口把内容抛给你的App代码,至于要不要弹通知、跳转哪个页面,由业务代码自己决定。适合App内状态同步、聊天消息、静默更新等场景。在同一个请求体里,notification 和 message 可以同时存在,也可以只写一种。很多人搞混的一点是:如果你只传 message,不传 notification,这条消息不会出现在系统通知栏。如果你的场景是“App收到消息后自己决定怎么表现”,那用message就对了;如果只是想弹一个通知栏,那用notification就够了。对应的Java参数设计:JsonObject message = new JsonObject(); message.addProperty("msg_content", "自定义消息内容"); message.addProperty("content_type", "text"); JsonObject extras = new JsonObject(); extras.addProperty("biz_type", "chat"); extras.addProperty("conversation_id", "abc123"); message.add("extras", extras); payload.add("message", message);extras是客户端收到消息后取业务参数的入口,比如页面跳转链接、订单号、会话ID等,尽量统一设计,不要每个业务方各自起一套。4.2 厂商通道参数:离线消息能不能被系统正常展示这是Android推送里最核心的一块。极光的REST API中,可以在notification.android里增加厂商相关字段,比如:{ "android": { "alert": "内容", "title": "标题", "extras": { "biz_type": "order" }, "channel_id": "default_channel" } }channel_id是Android 8.0 之后引入的通知渠道ID,如果客户端没有正确创建对应渠道,通知栏可能不展示或者提示“通知被拦截”。这个问题在客户端和服务端之间经常互相甩锅。服务端这边要做的,是确认参数里有客户端期望的 channel_id。如果你不传,极光SDK会尝试用默认渠道,但如果客户端没有创建默认渠道,部分机型上通知就会静默丢弃。至于厂商离线消息到达率,坦白说,这不是服务端调一次API就能保证的。它依赖厂商通道的token上报是否成功,依赖App在前台/后台/被杀三种状态下通道的存活情况。你只能在服务端尽量把apns_production、channel_id、mutable_content这些参数配正确,剩下的是客户端SDK和厂商SDK的集成职责。4.3 时间策略:延迟推送和过期时间推送不是越快越好。极光的options里有一个time_to_live参数,单位是秒。它表示这条消息在极光服务器上保留多久:超过这个时间设备仍然不在线,消息就会被丢弃,不再通过厂商通道补发。这个参数的取舍很有意思。设置太长,消息可能在下一次用户打开App时被推送出去,内容早已过期,体验很差;设置太短,用户只是离开网络一分钟就可能永久错过通知。我给不同场景的建议是:业务场景time_to_live设置原因验证码/安全提醒300-600秒时效性极强,过期无意义订单状态变更3600秒用户可能稍后查看,过期不太合理硬推运营活动1800-7200秒结合活动开始时间,太长容易打扰公告类通知86400秒保留到当天结束即可如果你的业务有明确的“只在某个时间段内可推送”的需求,options里还有schedule相关能力,不过定时推送一般在控制台或者专门的调度服务里做,REST API直接指定时间的场景不多。4.4 每天推多少条:限流和频控极光对REST API调用有频控限制,不同等级的账号阈值不同。免费档的限流级别大致是每分钟十几次到几十次不等,具体阈值以控制台显示的配额为准。如果你的业务要发大量个性化推送,一定要加好本地控制,避免突发流量把API限流打爆。限流错误在响应体里通常是Too Many Requests或者返回一个特定错误码。我建议服务端在PushProvider层统一捕获限流异常,做退避重试。同时把限流事件打上监控告警,因为一旦走到限流,说明业务侧可能出现了消息风暴或定时任务重叠执行,这时候算法退避能续命一时,但根因还得去业务侧查。5. 另一条路线:MobPush服务端集成与极光的异同5.1 MobPush的控制台与鉴权方式如果你们选用MobPush,服务端集成的思路和极光很像,但是细节上有些差异。MobPush控制台提供AppKey、AppSecret,服务端调用时要先通过这两个值换取访问令牌,再调用推送接口。具体端点和参数格式以MobPush官方文档为准,我这里分享的是它和极光不同的几个关键点。MobPush的推送目标是rid(也就是类似极光registrationId的设备标识),也支持别名、标签、区域等。客户端SDK初始化成功后会把rid上报给服务端,这一点和极光的设备标识思路一致。5.2 Java对接MobPush的核心流程MobPush的鉴权方式一般是先调令牌接口,然后把token放到推送请求的Header里。示例流程如下:public class MobPushClient { private static final String TOKEN_URL = "https://api.mob.com/push/token"; private static final String PUSH_URL = "https://api.mob.com/push/send"; private final OkHttpClient httpClient = new OkHttpClient(); private final String appKey; private final String appSecret; public MobPushClient(String appKey, String appSecret) { this.appKey = appKey; this.appSecret = appSecret; } public String getToken() throws IOException { JsonObject body = new JsonObject(); body.addProperty("appKey", appKey); body.addProperty("appSecret", appSecret); Request request = new Request.Builder() .url(TOKEN_URL) .post(RequestBody.create(body.toString().getBytes(), MediaType.parse("application/json; charset=utf-8"))) .build(); try (Response response = httpClient.newCall(request).execute()) { return response.body().string(); } } public String pushToRid(String rid, String title, String content) throws IOException { JsonObject targets = new JsonObject(); targets.addProperty("rid", rid); JsonObject pushInfo = new JsonObject(); pushInfo.addProperty("title", title); pushInfo.addProperty("content", content); JsonObject payload = new JsonObject(); payload.add("targets", targets); payload.add("pushInfo", pushInfo); // 注意这里需要完成token逻辑后带上 Request request = new Request.Builder() .url(PUSH_URL) .post(RequestBody.create(payload.toString().getBytes(), MediaType.parse("application/json; charset=utf-8"))) .build(); try (Response response = httpClient.newCall(request).execute()) { return response.body().string(); } } }看到没有,核心思路依然是:先拿到鉴权信息,再构造推送目标,最终提交推送内容。这和极光最大的差别只是鉴权前置多了一步token获取。5.3 选型建议:什么时候选极光,什么时候选MobPush我自己很少会只因为“哪个名气大”选推送平台,更多是看以下几点:对比维度极光推送MobPush厂商通道覆盖覆盖主流厂商覆盖主流厂商API风格REST直连,Basic Auth先换token再调用服务端SDK官方Java SDK较全官方服务端SDK和示例也齐全后台报表送达统计较完整也有完整报表需要实际体验国内使用体感文档和社区资料多文档相对少一些,但基本场景够用我的真实建议是:如果团队之前没有接触过任何推送平台,优先选极光,因为资料多、踩坑的同行多,搜一个问题基本都有答案。如果你所在公司已经用了MobTech的其他服务,或者客户方指定了MobPush,那直接用MobPush也没问题。服务端侧把两块封装成统一接口,后续要切换迁移,改动量是可控的。6. 实测中常见的五个坑与完整排查链路6.1 推送返回成功但用户收不到:排查顺序非常重要这是我遇到最多的问题。正确的排查顺序应该是:客户端SDK是否初始化成功→长连接通道状态→厂商通道是否注册→消息是否被系统展示→点击回调和展示回调有没有生效。先说客户端SDK初始化。Android客户端初始化极光SDK后,会触发一次本地广播,只有初始化成功才能拿到registrationId。你可以先在服务端日志里确认,推送时使用的registrationId确实是客户端刚上报的那个。很多时候服务端推送立刻成功,但客户端连初始化都没完成,registrationId是旧的或空的,推送当然到不了。然后是厂商通道注册。很多App只集成了极光SDK,没有集成厂商推送SDK,App在后台时还好,一旦被用户杀掉,极光的长连接断了,又没有厂商通道兜底,离线消息必丢。这个排查点不在服务端,但在整条链路里,你排查时绕不开。最后是系统通知权限。国内很多ROM默认不允许应用弹通知,或者用户之前手动关掉了通知权限。服务端无论如何调整参数,这种“客户端已收到消息但无法展示”的情况都查不出来,只能靠客户端SDK上报的展示回执来区分。6.2 回调与回执:怎么判断消息真的到了手机推送平台一般会提供送达回执或展示回执,极光后台能看到部分维度的投递量,但这些统计有延迟,不是实时精确的。服务端如果想自己做“未到达重推”的逻辑,我建议不要依赖全链路回执,因为Android厂商通道与第三方推送平台的回执打通质量参差不齐,很容易出现漏报。更可靠的做法是:重点业务(比如订单支付结果)推送后,让客户端在收到消息时主动调用业务接口上报一个“已收到”埋点。服务端如果在一定时间内没等到客户端确认消息,再补发一次。这种方案虽然多一次客户端埋点,但比依赖回执靠谱得多。6.3 测试环境和生产环境撞了:最常见的乌龙很多团队的测试机同时装了开发版和生产版App,同一个设备有时注册到Dev应用,有时注册到Prod应用。服务端往Prod应用推送时,这个设备的registrationId如果在Dev应用里是无效的,推送平台会直接返回找不到设备的错误。解决方法是:开发版和测试版App的推送环境要完全隔离,简单做法是测试机分机器、分包名。如果实在没法分,至少保证测试时在控制台筛选统计时能区分AppKey,别把生产环境的数据搞混。6.4 定时任务重复触发:消息风暴比你想得更常见推送模块很容易遇到定时任务重复执行:分布式调度框架重试、手动补跑、多副本同时执行,这些都可能导致同一批用户短时间内收到多条重复推送。我见过一个凌晨2点的营销推送,因为定时任务误配了每分钟触发一次,一个小时发了60遍,直接把用户投诉率推到峰值。服务端治理方案很简单:推送请求带上业务幂等标识(sendno或自定义唯一ID),另外在业务侧加一个“同一用户同一任务同一时间窗口内只推一次”的查重表。查重表可以用Redis,key设计成push:{taskId}:{userId},过期时间设为推送任务的执行周期。这个改动很小,但对稳定性的提升非常明显。6.5 HTTP通信层容易踩的坑:UTF-8、连接池、日志极光REST API返回的中文提示,如果你没有正确按UTF-8解码,日志里就会看到乱码;推送内容里的emoji符号发到iOS会有特殊转义问题,Android倒是宽容一些。我在封装请求的时候,全部统一使用UTF-8编码字符串,响应体也明确指定charset。HttpClient连接池也要记得配置。很多人直接用OkHttpClient默认配置,高并发推送时连接池不够用,出现大量Connection reset。我的习惯是用固定的OkHttpClient实例,配置好最大连接数和空闲超时,不要每次new一个。最后一点:推送请求的日志一定要打关键字段。包括目标registrationId/alias、msg_id、响应码、耗时、发送内容摘要。日志里不要直接打全量extras,因为可能包含手机号、订单号等敏感信息,用截断或只打业务类型即可。出了问题,这些日志就是最直接的排查入口。最后说几句实操体会这套东西我前前后后在几个项目里都实现过,也踩了不少跟头。最大的体会是:集成推送没有多高深,但一定要建立“端到端链路”的全局认知。遇到推送问题,不能只盯着自己的服务端代码,要能顺着链路一层层排查——客户端SDK、厂商通道注册、设备通知权限、测试环境配置,每一环都可能成为断点。如果你是想搭第一版推送服务,我会建议先用极光官方控制台和REST API手动推一条到自己的测试机,把整条链路跑通,再根据这篇里的封装思路,把这些逻辑沉淀到你们自己的推送服务里。跑通之后再慢慢加消息回执、重试、幂等、监控这些工程化能力。如果你们最终选了MobPush,思路也是一样的,无非是换一套鉴权方式和API端点,核心经验完全复用。
做Java服务端集成推送,我见过太多人把精力全花在客户端SDK上,结果服务端对接的时候反而被一堆琐碎问题卡住:registrationId 到底从哪拿、推送凭据怎么配、为什么消息发出去了手机却没响、为什么偶尔超时、为什么生产环境推不动。这篇就围绕集成极光推送(JPush)和 MobPush 这两条线,把服务端集成的完整链路、关键参数、限流策略和排查思路一次性讲清楚,适合正在接推送、或者准备从零开始搭推送服务的后端开发同学参考。先说一个我反复强调的观点:服务端集成推送,难点从来不在“调通接口”,而在“搞清楚推送的完整链路”。你只有知道一条消息从你的业务服务器出发,经过推送平台、厂商通道、系统通知栏,最终到达用户手机,这条路上每一站都有什么规则和坑,你才能设计出可靠的推送模块。这篇我也尽量不写“官方文档翻译稿”,只写实战里验证过的东西。1. 推送集成第一步:搞懂端到端链路,别一上来就写代码1.1 一条推送消息从服务端到用户手机的完整通路我们以极光推送为例,把一次推送拆成四段:第一段是业务服务端到极光服务器。你的Java后端通过 HTTPS 调用极光 REST API,带上 AppKey 和 Master Secret 鉴权,把推送内容、目标人群(registrationId 列表、标签、别名或广播)、过期时间等参数交给极光。第二段是极光服务器到客户端的长连接。客户端App嵌入了极光SDK,SDK会和极光服务器维持一条长连接,用于实时接收下发指令。如果App在前台或后台运行,长连接存活,消息秒到。第三段是厂商通道兜底。当App被用户杀掉,或者系统休眠导致长连接断开,极光会尝试通过设备所属厂商的系统推送通道(比如小米、华为、OPPO、vivo、荣耀各自的推送服务)来下发。这就是移动推送领域常说的“厂商通道”。第四段是系统通知栏展示。无论消息走的是长连接还是厂商通道,最终都要由手机系统把通知展示出来,并触发你预先写好的点击回调逻辑。这段链路里最容易出问题的就是第三段:第三方推送平台能保证自己服务器到App长连接这条路的可靠性,但“长连接断开后转入厂商通道”这件事,既有设备厂商的策略限制,也有你的App有没有正确集成厂商推送SDK的问题。很多人以为只要服务端调了接口,消息就一定到达,这就是最大的误解。1.2 Java服务端在整条链路里到底承担了什么角色搞清楚链路之后,你就能定位Java服务端的职责边界了:它只负责把“推送请求”可靠地交给推送平台,并处理推送平台的响应、回执和回调。客户端能不能收到、什么时候收到、走哪条通道,这些主要由客户端SDK集成、厂商通道注册、用户手机系统状态决定。这个边界意识非常重要。我遇到过一个团队,服务端推送接口调用成功后,用户没收到消息,他们一遍遍查服务端日志、调参数,折腾了一整天,最后发现是客户端在部分低版本Android机型上没能成功初始化厂商推送SDK,所以App被杀后就完全收不到离线消息。服务端代码改了几版都白搭。所以这篇里的所有操作,都默认遵循一个原则:服务端做到按规则正确送达、记录投递状态、合理重试,不越权替客户端承担本应由SDK负责的通道注册工作。如果你连客户端SDK是否初始化成功都无法确认,先别急着优化服务端。2. 账号体系与基础配置:环境隔离比想象中更重要2.1 极光应用基本信息:AppKey、Master Secret 和推送业务的关系登录极光控制台,创建应用后你会拿到AppKey和Master Secret。AppKey 是应用的身份标识,相当于“用户名”,在客户端SDK初始化和服务端API调用中都会用到;Master Secret 是服务端鉴权密钥,相当于“密码”,只允许保存在服务端,绝对不能下发到客户端。在REST API调用里,这两个值以 Basic Auth 的形式放在请求头中。极光要求请求头带上Authorization: Basic (base64(appkey:masterSecret))。写Java代码的时候,很多人会手动拼接base64,其实细看官方示例,更多是直接构造一个 BasicAuth 的 header 值。这里有一个很容易被忽略的点:AppKey 和 Master Secret 是配套的,如果你在控制台里重置了 Master Secret,服务端所有旧请求会立即失效,同时其他环境里的同名密钥也会跟着变。所以重置密钥前,先确保所有环境都已更新。2.2 环境隔离:开发、测试、生产要用不同的应用极光的账号体系里,一个极光账号下可以创建多个应用。很多团队只用同一个极光应用同时跑测试和线上,结果就会出现:测试机收到线上推送、线上用户收到测试环境的垃圾推送、调试时参数改来改去互相干扰。我的建议是至少拆三个应用:Dev应用:开发联调用,包名写开发包名,谁都别往里面发正式内容。Test应用:测试环境用,可以做自动化测试的推送验证。Prod应用:生产环境专用,Master Secret 只在配置中心或密钥管理系统中保存。每个应用拥有独立的 AppKey 和 Master Secret,客户端也通过不同的初始化参数指向不同环境。这样排查问题的时候,你可以直接根据AppKey确定问题归属,而不是在一堆混合日志里大海捞针。如果你们的客户端有多个渠道包(比如应用市场渠道、运营定制包),每个渠道包可能也要对应不同的包名或推送配置。这里我建议渠道包的推送配置尽量统一,否则服务端要同时管理多套 AppKey,投递统计还会被切碎,运营数据很难看。2.3 密钥管理:别把 Master Secret 写进代码仓库Master Secret 泄露意味着任何人只要拿到它,就能用你的应用名义向全量用户发推送。这不是危言耸听,极光的API设计里,服务端是可以通过 Master Secret 发广播的。所以我强烈建议:配置放到环境变量、配置中心或KMS中,不要硬编码在源码里。配置文件即使要做示例,也一律用占位符代替。定期轮换密钥,换完后立刻观察服务端日志确认没有401。我见过有人为了省事,把 Master Secret 直接写在项目的 application.yml 里,还提交到了Git仓库。后来仓库公开,很快就被别人盯上,给全量用户发了一堆垃圾推送。这种事故一旦发生,轻则卸载率飙升,重则影响应用在渠道的评级。3. 极光推送服务端集成实操:从鉴权到第一个推送请求3.1 选型:直接用REST API还是官方Java SDK极光官方提供 Java SDK,内部封装了鉴权、请求构造、响应解析等逻辑,使用上确实省事。但我个人的建议是:核心推送逻辑自己封装一层HTTP调用,不要只依赖SDK。原因有三个:第一,官方SDK升级节奏你不一定跟得上,如果某天你遇到一个诡异的bug,直接看SDK源码看不出来问题,而REST API的文档和排查资料更多。第二,推送请求本质上是普通HTTPS POST,你封装一次之后,超时、重试、日志、监控都可以按自己团队的标准来治理,而不是被SDK内部行为带着走。第三,很多场景下你们不只需要极光,还可能接MobPush或其它平台(比如厂商直连),自己封装一层统一的 PushProvider 接口,后面切换或扩展会非常容易。当然,如果你只是想快速跑通,直接用官方SDK没问题。下面我以REST API为主写完整示例,看完你也能轻松切换。3.2 推送目标:registrationId、alias、tag 怎么选推送目标决定一条消息发给谁。极光支持按registrationId(客户端设备ID)、alias(别名)、tag(标签)、segment(分群)以及广播来圈定受众。服务端开发最常见的目标是 registrationId 和 alias。registrationId 是客户端SDK成功初始化后由极光分配的一个设备标识,长这样一般是1503bfd6f42之类的字符串。业务服务端通常在用户登录或App启动时,把 registrationId 上报到自己库里,推送时再根据用户ID查出来。alias 更适合业务侧,比如你把用户ID设置为alias,那么可以直接sendToAlias。但要注意alias 的规则:同一应用下alias 全局唯一(至少极光设计上要求尽可能唯一),如果你在多个设备登录同一个账号,后设置的alias会覆盖之前的绑定关系,旧设备可能就收不到定向消息。我的经验是:上报 registrationId 到服务端,业务侧存一份“用户-设备”映射表,推送时先查表再发。alias可以做简化场景,但不要依赖它做精细化设备管理。因为业务系统往往有账号解绑、设备换绑、多设备登录这些复杂逻辑,直接用registrationId 更可控。3.3 核心实现:一封完整的推送请求长什么样我们用OkHttp构造一次推送请求。前提取Maven里引入:<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>极光REST API的推送端点是:POST https://api.jpush.cn/v3/push请求头:Authorization: Basic base64(AppKey:MasterSecret) Content-Type: application/json一个最简单的广播推送请求体:{ "audience": "all", "notification": { "android": { "alert": "你好,这是一条测试推送", "title": "测试标题", "extras": { "from": "order_system" } }, "ios": { "alert": "你好,这是一条测试推送", "sound": "default" } }, "options": { "apns_production": false } }Java代码对应如下:import okhttp3.*; import com.google.gson.JsonObject; import java.util.Base64; import java.util.concurrent.TimeUnit; public class JPushClient { private static final String PUSH_URL = "https://api.jpush.cn/v3/push"; private final OkHttpClient httpClient; private final String authHeader; public JPushClient(String appKey, String masterSecret) { String token = Base64.getEncoder() .encodeToString((appKey + ":" + masterSecret).getBytes()); this.authHeader = "Basic " + token; this.httpClient = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); } public String pushToDevice(String registrationId, String title, String alert, JsonObject extras) throws IOException { JsonObject payload = new JsonObject(); // 定向推送 JsonObject audience = new JsonObject(); audience.addProperty("registration_id", registrationId); payload.add("audience", audience); JsonObject android = new JsonObject(); android.addProperty("alert", alert); android.addProperty("title", title); if (extras != null) { android.add("extras", extras); } JsonObject notification = new JsonObject(); notification.add("android", android); payload.add("notification", notification); JsonObject options = new JsonObject(); options.addProperty("apns_production", false); // 测试环境 payload.add("options", options); Request request = new Request.Builder() .url(PUSH_URL) .post(RequestBody.create(payload.toString().getBytes(), MediaType.parse("application/json; charset=utf-8"))) .header("Authorization", authHeader) .build(); try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException("push failed, code=" + response.code() + ", body=" + response.body().string()); } return response.body().string(); } } }这段代码里有几个细节值得注意。第一,audience结构里写的是registration_id而非registrationId,下划线是API字段名,别用驼峰。这个问题我见过不下三次,请求体构造出来,服务端返回参数错误。第二,apns_production控制的是iOS端推送环境。开发证书填false,生产证书填true。这个参数一旦在生产环境配反了,iOS推送就会一直失败,而且失败日志只在极光后台能看到,客户端完全无感知。第三,Android 的notification里可以不写title,但建议写。很多机型通知栏对没有标题的通知展示效果不理想。3.4 推送响应的含义与幂等设计推送接口调用成功后,极光会返回类似这样的响应:{ "sendno": 140920140890, "msg_id": "2023122910300001", "statusCode": 0 }这里的msg_id是这次推送的唯一标识,后续查询送达统计、离线消息回执都需要它。sendno是调用方传的可选序号,主要用来做幂等。极光的文档里提到,为了防止重复推送,可以在请求参数中加入sendno。我的建议是:服务端每次生成推送请求时都带上一个业务侧唯一的sendno,比如雪花ID或业务单号,这样如果网络超时后你决定重试,平台能根据sendno判断是否已处理过,避免同一消息被重复下发。不过要提醒一下:理论上用了sendno能降低重复概率,但依赖它做严格幂等并不绝对可靠。真正要紧的业务通知(比如订单支付结果),还是要靠业务层再加一层逻辑保证——推送只是尽力而为的通知手段,不能把它当成强事务。3.5 超时和异常重试的一点体会推送接口是外部依赖,需要像对待数据库一样治理超时。OkHttp里我一般把 connectTimeout 设成10秒,read/write 30秒。如果出现SocketTimeoutException,说明极大可能是请求已经到达极光服务器,只是响应返回慢或网络抖动,这时直接重试同一个请求,大概率没问题。但要注意:不是所有异常都适合无脑重试。如果是4xx,比如401鉴权失败、400参数错误、403无权限,重试多少次都一样,这时候要优先查配置和参数,不要一直在业务代码里循环重试把日志打爆。如果是5xx或网络异常,可以按指数退避重试,最多两到三次,不要无限重试。4. 关键参数与投放策略:透传、通知栏和厂商通道的取舍4.1 notification、message、options 三者的关系在极光的REST API中,推送内容分为两个大类:notification和message。notification是通知栏消息,由系统直接展示,不经过你的业务代码,点击行为通过回调告知客户端。适合运营活动、站外通知等场景。message是自定义消息(也叫透传消息),SDK收到后不会自动展示,而是通过回调接口把内容抛给你的App代码,至于要不要弹通知、跳转哪个页面,由业务代码自己决定。适合App内状态同步、聊天消息、静默更新等场景。在同一个请求体里,notification 和 message 可以同时存在,也可以只写一种。很多人搞混的一点是:如果你只传 message,不传 notification,这条消息不会出现在系统通知栏。如果你的场景是“App收到消息后自己决定怎么表现”,那用message就对了;如果只是想弹一个通知栏,那用notification就够了。对应的Java参数设计:JsonObject message = new JsonObject(); message.addProperty("msg_content", "自定义消息内容"); message.addProperty("content_type", "text"); JsonObject extras = new JsonObject(); extras.addProperty("biz_type", "chat"); extras.addProperty("conversation_id", "abc123"); message.add("extras", extras); payload.add("message", message);extras是客户端收到消息后取业务参数的入口,比如页面跳转链接、订单号、会话ID等,尽量统一设计,不要每个业务方各自起一套。4.2 厂商通道参数:离线消息能不能被系统正常展示这是Android推送里最核心的一块。极光的REST API中,可以在notification.android里增加厂商相关字段,比如:{ "android": { "alert": "内容", "title": "标题", "extras": { "biz_type": "order" }, "channel_id": "default_channel" } }channel_id是Android 8.0 之后引入的通知渠道ID,如果客户端没有正确创建对应渠道,通知栏可能不展示或者提示“通知被拦截”。这个问题在客户端和服务端之间经常互相甩锅。服务端这边要做的,是确认参数里有客户端期望的 channel_id。如果你不传,极光SDK会尝试用默认渠道,但如果客户端没有创建默认渠道,部分机型上通知就会静默丢弃。至于厂商离线消息到达率,坦白说,这不是服务端调一次API就能保证的。它依赖厂商通道的token上报是否成功,依赖App在前台/后台/被杀三种状态下通道的存活情况。你只能在服务端尽量把apns_production、channel_id、mutable_content这些参数配正确,剩下的是客户端SDK和厂商SDK的集成职责。4.3 时间策略:延迟推送和过期时间推送不是越快越好。极光的options里有一个time_to_live参数,单位是秒。它表示这条消息在极光服务器上保留多久:超过这个时间设备仍然不在线,消息就会被丢弃,不再通过厂商通道补发。这个参数的取舍很有意思。设置太长,消息可能在下一次用户打开App时被推送出去,内容早已过期,体验很差;设置太短,用户只是离开网络一分钟就可能永久错过通知。我给不同场景的建议是:业务场景time_to_live设置原因验证码/安全提醒300-600秒时效性极强,过期无意义订单状态变更3600秒用户可能稍后查看,过期不太合理硬推运营活动1800-7200秒结合活动开始时间,太长容易打扰公告类通知86400秒保留到当天结束即可如果你的业务有明确的“只在某个时间段内可推送”的需求,options里还有schedule相关能力,不过定时推送一般在控制台或者专门的调度服务里做,REST API直接指定时间的场景不多。4.4 每天推多少条:限流和频控极光对REST API调用有频控限制,不同等级的账号阈值不同。免费档的限流级别大致是每分钟十几次到几十次不等,具体阈值以控制台显示的配额为准。如果你的业务要发大量个性化推送,一定要加好本地控制,避免突发流量把API限流打爆。限流错误在响应体里通常是Too Many Requests或者返回一个特定错误码。我建议服务端在PushProvider层统一捕获限流异常,做退避重试。同时把限流事件打上监控告警,因为一旦走到限流,说明业务侧可能出现了消息风暴或定时任务重叠执行,这时候算法退避能续命一时,但根因还得去业务侧查。5. 另一条路线:MobPush服务端集成与极光的异同5.1 MobPush的控制台与鉴权方式如果你们选用MobPush,服务端集成的思路和极光很像,但是细节上有些差异。MobPush控制台提供AppKey、AppSecret,服务端调用时要先通过这两个值换取访问令牌,再调用推送接口。具体端点和参数格式以MobPush官方文档为准,我这里分享的是它和极光不同的几个关键点。MobPush的推送目标是rid(也就是类似极光registrationId的设备标识),也支持别名、标签、区域等。客户端SDK初始化成功后会把rid上报给服务端,这一点和极光的设备标识思路一致。5.2 Java对接MobPush的核心流程MobPush的鉴权方式一般是先调令牌接口,然后把token放到推送请求的Header里。示例流程如下:public class MobPushClient { private static final String TOKEN_URL = "https://api.mob.com/push/token"; private static final String PUSH_URL = "https://api.mob.com/push/send"; private final OkHttpClient httpClient = new OkHttpClient(); private final String appKey; private final String appSecret; public MobPushClient(String appKey, String appSecret) { this.appKey = appKey; this.appSecret = appSecret; } public String getToken() throws IOException { JsonObject body = new JsonObject(); body.addProperty("appKey", appKey); body.addProperty("appSecret", appSecret); Request request = new Request.Builder() .url(TOKEN_URL) .post(RequestBody.create(body.toString().getBytes(), MediaType.parse("application/json; charset=utf-8"))) .build(); try (Response response = httpClient.newCall(request).execute()) { return response.body().string(); } } public String pushToRid(String rid, String title, String content) throws IOException { JsonObject targets = new JsonObject(); targets.addProperty("rid", rid); JsonObject pushInfo = new JsonObject(); pushInfo.addProperty("title", title); pushInfo.addProperty("content", content); JsonObject payload = new JsonObject(); payload.add("targets", targets); payload.add("pushInfo", pushInfo); // 注意这里需要完成token逻辑后带上 Request request = new Request.Builder() .url(PUSH_URL) .post(RequestBody.create(payload.toString().getBytes(), MediaType.parse("application/json; charset=utf-8"))) .build(); try (Response response = httpClient.newCall(request).execute()) { return response.body().string(); } } }看到没有,核心思路依然是:先拿到鉴权信息,再构造推送目标,最终提交推送内容。这和极光最大的差别只是鉴权前置多了一步token获取。5.3 选型建议:什么时候选极光,什么时候选MobPush我自己很少会只因为“哪个名气大”选推送平台,更多是看以下几点:对比维度极光推送MobPush厂商通道覆盖覆盖主流厂商覆盖主流厂商API风格REST直连,Basic Auth先换token再调用服务端SDK官方Java SDK较全官方服务端SDK和示例也齐全后台报表送达统计较完整也有完整报表需要实际体验国内使用体感文档和社区资料多文档相对少一些,但基本场景够用我的真实建议是:如果团队之前没有接触过任何推送平台,优先选极光,因为资料多、踩坑的同行多,搜一个问题基本都有答案。如果你所在公司已经用了MobTech的其他服务,或者客户方指定了MobPush,那直接用MobPush也没问题。服务端侧把两块封装成统一接口,后续要切换迁移,改动量是可控的。6. 实测中常见的五个坑与完整排查链路6.1 推送返回成功但用户收不到:排查顺序非常重要这是我遇到最多的问题。正确的排查顺序应该是:客户端SDK是否初始化成功→长连接通道状态→厂商通道是否注册→消息是否被系统展示→点击回调和展示回调有没有生效。先说客户端SDK初始化。Android客户端初始化极光SDK后,会触发一次本地广播,只有初始化成功才能拿到registrationId。你可以先在服务端日志里确认,推送时使用的registrationId确实是客户端刚上报的那个。很多时候服务端推送立刻成功,但客户端连初始化都没完成,registrationId是旧的或空的,推送当然到不了。然后是厂商通道注册。很多App只集成了极光SDK,没有集成厂商推送SDK,App在后台时还好,一旦被用户杀掉,极光的长连接断了,又没有厂商通道兜底,离线消息必丢。这个排查点不在服务端,但在整条链路里,你排查时绕不开。最后是系统通知权限。国内很多ROM默认不允许应用弹通知,或者用户之前手动关掉了通知权限。服务端无论如何调整参数,这种“客户端已收到消息但无法展示”的情况都查不出来,只能靠客户端SDK上报的展示回执来区分。6.2 回调与回执:怎么判断消息真的到了手机推送平台一般会提供送达回执或展示回执,极光后台能看到部分维度的投递量,但这些统计有延迟,不是实时精确的。服务端如果想自己做“未到达重推”的逻辑,我建议不要依赖全链路回执,因为Android厂商通道与第三方推送平台的回执打通质量参差不齐,很容易出现漏报。更可靠的做法是:重点业务(比如订单支付结果)推送后,让客户端在收到消息时主动调用业务接口上报一个“已收到”埋点。服务端如果在一定时间内没等到客户端确认消息,再补发一次。这种方案虽然多一次客户端埋点,但比依赖回执靠谱得多。6.3 测试环境和生产环境撞了:最常见的乌龙很多团队的测试机同时装了开发版和生产版App,同一个设备有时注册到Dev应用,有时注册到Prod应用。服务端往Prod应用推送时,这个设备的registrationId如果在Dev应用里是无效的,推送平台会直接返回找不到设备的错误。解决方法是:开发版和测试版App的推送环境要完全隔离,简单做法是测试机分机器、分包名。如果实在没法分,至少保证测试时在控制台筛选统计时能区分AppKey,别把生产环境的数据搞混。6.4 定时任务重复触发:消息风暴比你想得更常见推送模块很容易遇到定时任务重复执行:分布式调度框架重试、手动补跑、多副本同时执行,这些都可能导致同一批用户短时间内收到多条重复推送。我见过一个凌晨2点的营销推送,因为定时任务误配了每分钟触发一次,一个小时发了60遍,直接把用户投诉率推到峰值。服务端治理方案很简单:推送请求带上业务幂等标识(sendno或自定义唯一ID),另外在业务侧加一个“同一用户同一任务同一时间窗口内只推一次”的查重表。查重表可以用Redis,key设计成push:{taskId}:{userId},过期时间设为推送任务的执行周期。这个改动很小,但对稳定性的提升非常明显。6.5 HTTP通信层容易踩的坑:UTF-8、连接池、日志极光REST API返回的中文提示,如果你没有正确按UTF-8解码,日志里就会看到乱码;推送内容里的emoji符号发到iOS会有特殊转义问题,Android倒是宽容一些。我在封装请求的时候,全部统一使用UTF-8编码字符串,响应体也明确指定charset。HttpClient连接池也要记得配置。很多人直接用OkHttpClient默认配置,高并发推送时连接池不够用,出现大量Connection reset。我的习惯是用固定的OkHttpClient实例,配置好最大连接数和空闲超时,不要每次new一个。最后一点:推送请求的日志一定要打关键字段。包括目标registrationId/alias、msg_id、响应码、耗时、发送内容摘要。日志里不要直接打全量extras,因为可能包含手机号、订单号等敏感信息,用截断或只打业务类型即可。出了问题,这些日志就是最直接的排查入口。最后说几句实操体会这套东西我前前后后在几个项目里都实现过,也踩了不少跟头。最大的体会是:集成推送没有多高深,但一定要建立“端到端链路”的全局认知。遇到推送问题,不能只盯着自己的服务端代码,要能顺着链路一层层排查——客户端SDK、厂商通道注册、设备通知权限、测试环境配置,每一环都可能成为断点。如果你是想搭第一版推送服务,我会建议先用极光官方控制台和REST API手动推一条到自己的测试机,把整条链路跑通,再根据这篇里的封装思路,把这些逻辑沉淀到你们自己的推送服务里。跑通之后再慢慢加消息回执、重试、幂等、监控这些工程化能力。如果你们最终选了MobPush,思路也是一样的,无非是换一套鉴权方式和API端点,核心经验完全复用。