
你第一次接触“推送消息到手机”这个需求时很可能觉得很简单后端调用一个接口手机端收到通知完事。但等真把App装到测试机上你很快会遇到一系列让人困惑的现象——App刚退到后台就收不到消息了换了一台安卓手机行为完全不一样iOS那边永远比你预想得多一道签名流程。更麻烦的是很多后台推送服务显示“发送成功”用户手机上却什么都没有。这篇文章想解释清楚一个核心判断消息推送的难点不是“把消息发出去”而是“如何在设备不在线、进程被杀、用户关掉通知权限的情况下仍然把消息可靠地送到用户眼前”。如果你只是写个接口让App每秒轮询一次开发环境看着没问题线上大概率会崩。真正适合长期使用的推送体系需要先做好技术选型再决定是自己搭一套通道还是接入厂商推送SDK。文章会先讲清楚消息推送的基本原理然后给出一份选型清单接着用最小成本搭建一条“服务端 → EMQX → Android手机”的完整推送链路。你可以通过一条命令触发订单状态推送在手机端实时收到结果。最后我会把生产环境里那些真正容易踩坑的地方和排查思路列出来。这是一篇偏向实践的文章适合后端开发、App开发以及准备做IoT或内部工具通知的开发团队阅读。1. 这篇文章真正要解决的问题很多团队接到“推送消息到手机”的需求时第一反应是扩展现有的HTTP接口App每隔几秒去服务端查一次状态。这种方案有个形象的称呼叫“轮询”。轮询在最简单的场景下确实能工作但它有几个隐藏成本。首先轮询造成大量无效请求。用户没有新消息时App仍然要每隔几秒发一次请求这浪费用户流量也浪费服务端连接资源。想象一下如果有1万个在线用户每个用户每5秒请求一次服务端每秒要扛2000次请求而其中绝大多数请求根本拿不到新数据。其次轮询的实时性永远受“轮询间隔”限制。间隔设短了浪费资源设长了用户会觉得消息来得太慢。更麻烦的是当App进程被系统回收后轮询代码根本没机会执行消息自然就收不到。推送和普通请求的核心差别在于普通请求是“客户端主动去取”推送是“服务端主动把消息塞给客户端”。要做到后者客户端必须和推送服务保持一个实时在线的长连接。长连接由谁来维护、进程被杀后还能不能继续工作这决定了不同推送方案的能力边界。看完本文你应该能回答下面几个问题为什么不能靠HTTP轮询顶替推送APNs、厂商通道、聚合Push、自建MQTT分别解决什么问题从零搭一条自建推送链路需要哪些组件为什么服务端显示发送成功手机却收不到通知生产环境下自建推送有哪些绕不开的工程细节2. 消息推送核心原理Push与Pull的本质差异2.1 Push与Pull的区别Push和Pull描述的是消息流动的方向。Pull是客户端主动询问服务端“有没有新数据”服务端被请求后才返回结果。Push是服务端一旦产生新消息就主动通过长连接把数据推给客户端。日常开发里最常见的Pull就是轮询。它的优点是实现简单服务端只要提供普通HTTP接口即可不需要维护额外的长连接服务。缺点是实时性受制于轮询周期而且服务端无法主动感知客户端是否还活着。Push则反过来服务端维护与客户端的连接状态消息一来就往下发。它的实时性更好省去大量无效请求但复杂度明显更高需要额外设计消息通道、连接保活、离线消息等机制。用生活中的例子来理解Pull就像你去快递柜取件快递到了先放在柜子里你有空就去刷一下柜子看看有没有新包裹Push则像快递员直接打电话告诉你“包裹到了”。前者需要你反复检查后者不需要你主动关心前提是快递员知道你的手机号且你的手机开机。2.2 系统级长连接与App自建长连接真正影响推送到达率的关键是长连接由谁来维护。系统级长连接是指操作系统自己维护的一条连接。比如iOS的APNs、安卓手机厂商的推送服务它们作为系统组件常驻App即使没有被启动系统收到新消息后也能通过通知栏把内容展示给用户。这个过程对App来说是透明的你的App进程是否活着通常不影响消息的最终展示。这是市面上主流App能够做到“杀不掉”通知的核心原因。App自建长连接则是指App自己启动一个网络连接在后台维持与某个推送服务器的通信。比如接入一个自己的WebSocket服务或者通过MQTT保持一条TCP长连接。这种连接一旦App进程被杀连接就断了消息也就收不到了。除非你的App能够成为系统白名单应用或者用户手动允许App后台自启动否则自建通道很难保证高到达率。这也是判断一个Push方案能不能用于面向大众用户的C端产品时最核心的一个分水岭。系统级通道做不好App保住不了那消息到达率就一定会打折扣。2.3 MQTT协议为什么适合推送场景如果把“推送”抽象成“让一个消息从服务端到达指定设备”那么MQTT是一个非常适合的协议候选。MQTT全称是Message Queuing Telemetry Transport最初是为低带宽、不可靠网络环境设计的轻量消息协议。它的核心模型是发布/订阅发送者把消息发布到某个主题订阅了这个主题的客户端会收到消息。MQTT不太像传统消息队列倒更像一个“群聊”谁在群里订阅了这个主题谁就能收到消息。服务端不用显式维护“每个设备该发什么”的地址表设备连上来之后自己订阅即可。一对多、多对多都方便。MQTT的报文头部开销非常小适合移动网络它还支持QoS 0、1、2三种投递级别服务端可以按业务要求选择是否需要确认消息送达。需要注意的是不要把MQTT和Kafka混为一谈。Kafka面向大数据场景下的流式吞吐侧重消息堆积与顺序处理MQTT面向物联网和移动弱网侧重设备连接数、低带宽和弱网稳定性。如果只是给几千或几万台设备推送简单状态MQTT是非常轻量的选择如果你要做海量日志收集或事件流分析那应该考虑Kafka这类消息中间件。2.4 名词澄清推送消息、通知栏消息、透传消息不少新手会混淆“推送消息”和“通知栏消息”。在多数推送SDK的语境里通知栏消息是指系统收到消息后直接展示成一条系统通知透传消息则是指服务端把一段自定义数据发给AppApp决定如何处理是否弹通知、弹什么样的通知都由App自己控制。通知栏消息的好处是不需要App进程在前台系统会展示坏处是展示样式和点击跳转让渡给了系统或SDK的默认行为。透传消息的好处是灵活App拿到数据后可以更新页面、播放声音、执行静默任务等坏处是如果App进程不在消息大概率收不到至少不能保证及时处理。做技术选型时一定要先确认你要的是“让用户看到一条通知”还是“让App后台更新数据”。很多需求表面上写着“推送消息到手机”实际拆解后做的却是另一件事。比如App内小红点数量更新完全可以在前台通过WebSocket更新不需要走厂商推送通道但用户关闭App后收到营销活动通知那才需要系统级通道。3. 技术选型系统推送、厂商聚合与自建推送3.1 主流推送方案对比方案维护方核心优势主要限制适用场景APNs苹果iOS系统统一推送进程被杀也能收到只覆盖苹果设备且需要配置证书和密钥iOS AppFCMGoogle安卓官方方案功能完整依赖Google Play服务国内安卓设备接入范围有限海外安卓应用厂商通道小米、华为、OPPO、vivo等系统级推送各厂商后台保活能力强需逐一接入各家SDK各家能力不一致国内安卓C端App聚合推送极光、个推、友盟等一套SDK封装多厂商通道开发成本低受第三方SDK稳定性影响部分能力需付费不想逐个适配厂商的团队自建推送开发团队自己完全可控消息格式自定义数据不出内网到达率依赖进程存活需要自己维护长连接内部工具、IoT、局域网、学习实验如果做国内用户量较大的C端安卓App比较务实的路径是集成厂商推送SDK或者用一个聚合推送平台。原因不是聚合推送平台的技术更高级而是它降低了“同时对接小米、华为、OPPO、vivo等厂商通道”的工程成本。每个厂商都有自己的SDK、AppId、密钥和后台配置团队自己逐个适配和长期维护的代价很高。如果产品主要面向iOS那绕不开APNs。APNs由苹果统一提供理论上所有iOS设备都能收到推送不用区分厂商。但APNs要求开发者配置推送证书或token且在开发阶段证书配置错误会直接导致推送失败排错时需要检查证书、Bundle Identifier、Device Token等多个环节。3.2 什么时候适合自建推送链路很多人一听到自建推送就摇头理由是App进程被杀死后消息仍然收不到。这个判断是对的但并不意味着自建方案没有使用场景。如果你的用户就是机构内部的几十台或几百台手机这些手机可以要求用户打开自启动权限、忽略电池优化甚至作为对公设备集中管理那自建通道完全够用。常见场景包括企业内部的审批提醒、仓库扫码枪上的任务分发、实验室仪器状态告警、售货机故障通知。这一类设备的特点是可控性强App可以长期驻留后台使用方的容忍度高技术团队能直接接触真机出现问题能及时调整。如果你的数据链路不允许经过第三方推送平台例如内网设备控制指令、包含敏感数据的业务提醒自建一条消息通道更为稳妥。第三方推送平台通常会把消息汇聚到厂商服务器虽然数据也会进行各种处理但在合规和数据自主性上自建方案有天然优势。如果你的目的只是学习消息协议、做技术预研或参加竞赛那自建一遍MQTT推送链路更是值得做的事情。做完一遍之后再去看厂商推送SDK的文档你会发现很多概念是相通的。3.3 为什么我不建议业务初期直接自建“C端可达”推送这里要给一个明确的冷水不要因为看了几篇技术文章就觉得自己可以做一条到达率很高的“通用推送系统”。C端安卓用户量大后系统版本、厂商ROM、用户是否关闭通知权限、是否开启了省电模式各种变量会把到达率拖到一个很难看的水平。厂商推送通道能解决的问题正是靠厂商系统进程实现的App自己拉起的长连接很难在用户清后台后继续存活。所以自建链路更适合作为可控环境的底座而不是替代厂商推送的银弹。如果产品团队说“我们要自建推送因为不想接入第三方”可以先反问一句这个需求是不是只面向内部可控设备如果不是大概率还是要采用“厂商通道为主自建通道作为补充”的混合方案。4. 自建推送链路整体架构与环境准备4.1 架构设计本文要实现的链路由三个角色组成业务服务端负责生成业务消息例如“订单已支付”“设备温度告警”然后把消息发布到MQTT Broker。MQTT Broker接收消息并转发给所有订阅了对应主题的客户端。本文使用EMQX它是一个开源分布式MQTT Broker支持通过Docker快速启动。手机端作为一个MQTT客户端连接到同一个Broker并订阅消息主题。连接成功后Broker一旦收到发布的消息手机端就能实时收到。画成流程就是业务服务端(Publisher) - EMQX Broker - 手机端(Subscriber)在这个架构里EMQX承担的是“消息通道”的角色。你不需要在业务服务端维护与每一台手机的连接状态只需要把消息准确投递给Broker。Broker到底发给哪些设备由设备订阅的主题决定。4.2 环境清单为了避免浪费时间先把实验环境列好一台电脑用于运行Docker和Python脚本。Docker用于启动EMQX Broker。Python 3.8或更高版本用于编写消息发布脚本。一台Android手机和一台上网环境正常的电脑手机与电脑建议在同一个局域网内。可选Android Studio用于在自己的App工程里集成MQTT订阅客户端。版本方面EMQX的镜像请以官方Docker Hub上的版本为准本文用的是比较常见的emqx/emqx镜像。Python依赖使用paho-mqtt库Android端示例使用Eclipse Paho Java Client版本以项目当前能拉到的稳定版本为准。版本差异不会影响教程主流程。4.3 启动EMQX Broker如果电脑已经安装Docker启动EMQX非常快。打开终端执行docker run -d \ --name emqx \ -p 1883:1883 \ -p 8083:8083 \ -p 18083:18083 \ emqx/emqx:latest参数说明1883MQTT TCP协议端口手机和Python客户端都会通过这个端口连接。8083MQTT WebSocket端口如果以后想在浏览器里调试可以保留。18083EMQX Dashboard控制台端口可以在这里查看连接和订阅状态。启动后打开浏览器访问http://127.0.0.1:18083使用默认控制台账号admin和密码public登录。如果能够正常看到Dashboard说明Broker已经启动成功。由于镜像版本会更新如果你在某个版本里发现默认账号登录不进去请以官方文档为准通常你在初始化时也可能需要修改初始密码。如果你的EMQX部署在云服务器上记得在云控制台的安全组中放行1883端口。如果是在局域网实验则要确保手机和电脑访问的是同一个局域网电脑防火墙没有拦截1883端口。5. 完整自建推送链路实现5.1 第一步用Python发布一条MQTT消息首先安装Python端MQTT客户端库pip install paho-mqtt然后创建一个文件publisher.py内容如下# 文件路径server/publisher.py import paho.mqtt.client as mqtt BROKER_ADDRESS 127.0.0.1 BROKER_PORT 1883 TOPIC demo/order/status def publish_order_event(order_id: str, status: str): client mqtt.Client() client.connect(BROKER_ADDRESS, BROKER_PORT, keepalive60) client.loop_start() payload {orderId:%s,status:%s} % (order_id, status) result client.publish(TOPIC, payload, qos1) result.wait_for_publish(timeout5) print(消息发布成功:, payload) client.loop_stop() client.disconnect() if __name__ __main__: publish_order_event(20250101001, PAID)这段代码做的事情很直观创建一个MQTT客户端连接到本机EMQX Broker把一条JSON消息发布到demo/order/status主题。如果这条消息将来要发给特定用户可以在业务逻辑里把orderId换成用户维度或者把主题设计成user/{userId}/order/status。qos1意思是消息至少要送达一次。设置QoS后Broker收到消息会给发布端一个确认如果网络抖动导致确认超时客户端会尝试重发从而降低消息丢失的概率。当然QoS不是越高越好QoS 2虽然能保证不重复但开销更大在大多数推送场景中QoS 1已经够了。运行脚本python publisher.py正常输出是消息发布成功: {orderId:20250101001,status:PAID}到这里第一条MQTT消息已经进入了EMQX Broker。但因为你没有订阅端在线这条消息发布后如果没有被任何客户端订阅Broker默认不会帮它做持久化等待它会被直接丢弃。所以下一步需要把手机端订阅者准备好。5.2 第二步用手机上的MQTT调试App验证通道在最开始做链路验证时不建议一步到位写完整App。先用一个现成的MQTT客户端工具把通道验证通了再把工具换成自己的工程排查范围会小很多。在手机上安装一个支持MQTT的测试工具例如MQTT X、MQTT Tool或者任意你能找到的MQTT调试客户端。App安装完成后配置连接Host: 填写运行EMQX电脑的局域网IP。Port: 1883。Client ID: 可以直接生成一个随机值也可以手动填一个不重复的ID。Username/Password: 测试阶段如果EMQX开启了匿名访问可以不填如果填了要保证Broker里有对应的账号。连接成功后订阅主题demo/order/status订阅后再次运行Python发布脚本python publisher.py这时手机MQTT工具应该实时收到一条JSON消息并能看到完整的payload内容。这一步如果通了说明“服务端 → Broker → 手机”这条链路是正常的。接下来才需要考虑在自己的App里集成MQTT。5.3 第三步在自己的Android工程中接收推送在实际App工程中接入MQTT无非是加依赖、配置权限、写连接与订阅逻辑。下面给出一个最小但能用的Android工程示例。首先在app/build.gradle中添加依赖dependencies { implementation org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5 }在AndroidManifest.xml中声明网络权限并注册一个Serviceuses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.WAKE_LOCK / application ... service android:name.PushService android:exportedfalse / /application这里把PushService声明为不对外导出的普通Service避免被其他应用拉起。实际生产项目中如果要保证App在后台不被系统很快回收通常需要把它设计成前台服务并展示常驻通知但前台服务在不同Android版本上的限制并不相同接入时请以你实际运行的Android版本为准。然后新建一个PushService.kt核心代码如下// 文件路径app/src/main/java/com/example/pushdemo/PushService.kt package com.example.pushdemo import android.app.Service import android.content.Intent import android.os.IBinder import android.util.Log import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken import org.eclipse.paho.client.mqttv3.MqttCallback import org.eclipse.paho.client.mqttv3.MqttClient import org.eclipse.paho.client.mqttv3.MqttConnectOptions import org.eclipse.paho.client.mqttv3.MqttMessage import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence class PushService : Service(), MqttCallback { companion object { private const val TAG PushService private const val BROKER_URL tcp://192.168.1.10:1883 private const val TOPIC demo/order/status } private lateinit var client: MqttClient override fun onCreate() { super.onCreate() Thread { connectMqtt() }.start() } private fun connectMqtt() { try { val options MqttConnectOptions().apply { isCleanSession true connectionTimeout 10 keepAliveInterval 30 isAutomaticReconnect false } client MqttClient( BROKER_URL, MqttClient.generateClientId(), MemoryPersistence() ) client.setCallback(this) client.connect(options) client.subscribe(TOPIC, 1) Log.d(TAG, MQTT连接成功并已订阅主题) } catch (e: Exception) { Log.e(TAG, MQTT连接失败, e) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { return START_STICKY } override fun onDestroy() { if (::client.isInitialized) { try { client.disconnect() } catch (e: Exception) { Log.e(TAG, MQTT断开异常, e) } } super.onDestroy() } override fun connectionLost(cause: Throwable?) { Log.w(TAG, 连接断开: ${cause?.message}) } override fun messageArrived(topic: String?, message: MqttMessage?) { val payload String(message?.payload ?: ByteArray(0)) Log.d(TAG, 收到消息主题: $topic) Log.d(TAG, 收到消息内容: $payload) } override fun deliveryComplete(token: IMqttDeliveryToken?) { // 只有发布消息时才会回调这里暂不处理 } override fun onBind(intent: Intent?): IBinder? null }在MainActivity中的某个按钮点击或初始化逻辑里启动该Service// 示例MainActivity.kt 的 onCreate 或点击事件中 startService(Intent(this, PushService::class.java))注意示例代码将EMQX Broker地址写死成了192.168.1.10实际使用时需要替换为你电脑在局域网中的IP地址并保证手机可以访问到该IP。也可以用adb reverse tcp:1883 tcp:1883把手机端口反向映射到电脑再做本地调试但这只适合USB连接调试模式正式环境还是要指定真实Broker地址。这段代码目前只会把收到的消息打到Logcat里。如果你想把它展示到通知栏需要额外处理Android 13及以上版本的通知权限申请并在收到消息后调用NotificationManager.notify。这些是独立的功能模块不建议在一次Demo里把所有逻辑都堆进去否则出现问题不容易定位。5.4 代码逻辑解读上面这段示例代码有几点值得展开说明。连接动作被放到一个后台线程里执行是因为client.connect()涉及网络I/O不能放在Android主线程否则会抛出NetworkOnMainThreadException。使用Thread {}是一种简单粗暴的写法实际项目中建议使用协程、线程池或专门的连接管理组件避免频繁创建线程。isCleanSession true表示每次连接都开启一个新的会话服务端不保存这个客户端的离线消息。如果希望客户端离线期间收到的消息在下次上线时补发需要把isCleanSession设为false并配合Broker的保留消息或持久会话能力。但这是一个容易混淆的配置项不同Broker在持久会话上的行为不一定相同测试时要特别留意。keepAliveInterval 30表示客户端每30秒发送一次心跳让Broker知道这个客户端还活着。如果Broker在超过一定时间没有收到心跳就会认为连接断开并清理会话。心跳间隔设得太小会增加网络开销设得太大则会让服务端误判客户端已经离开。在移动网络下30到60秒是比较常见的经验区间但不同网络环境可能需要调优。connectionLost回调是排查问题的一个关键入口。网络切换、服务端重启、心跳超时都可能导致连接断开。生产项目里通常会在connectionLost中做退避重连而不是立刻死循环重连否则大量设备同时断线后会形成“重连风暴”把Broker打挂。6. 运行结果与效果验证6.1 按照正确的顺序验证如果你在从头搭这条链路建议严格遵循下面的顺序不要跳过验证步骤。第一步确认EMQX Dashboard能访问。浏览器打开http://127.0.0.1:18083能看到可视化控制台说明Broker运行正常。第二步运行Python发布脚本同时用一个桌面端MQTT客户端或者手机MQTT调试App订阅主题。如果收不到消息优先检查防火墙和网络连通性。可以使用telnet 127.0.0.1 1883测试本机1883端口是否通如果手机连不上可以先在电脑上用telnet 192.168.1.10 1883验证端口是否可达。第三步启动Android工程点击按钮或者触发onCreate中的startService后看Logcat中是否打印“MQTT连接成功并已订阅主题”。如果没看到说明手机连接Broker失败要看connectMqtt的异常堆栈。第四步再运行一次Python发布脚本预期Logcat会打印收到消息主题: demo/order/status 收到消息内容: {orderId:20250101001,status:PAID}这时整条链路就算完整跑通了。你从服务端发布一条消息经过EMQX转发被Android手机里的Service收到。6.2 如果失败先看哪里链路失败时最忌讳的是到处乱改。下面是一个推荐的排查顺序Broker是否有服务在监听在电脑上执行docker ps看EMQX容器是否处于Up状态。容器如果反复重启要看docker logs emqx的输出。端口是否监听执行netstat -ano | findstr 1883Windows或lsof -i:1883macOS/Linux确认1883端口被监听。网络是否连通从手机端访问电脑IP的1883端口。最简单的测试可以在电脑上启动一个临时HTTP服务看手机能不能访问同一IP的其它端口但TCP端口是否可达最好还是用端口扫描或MQTT工具连接来判断。MQTT客户端是否报错查看Android Logcat或者MQTT工具给出的异常信息。很多时候错误信息已经直接告诉你连接超时、认证失败还是权限不够。是否订阅了正确的主题发布端和订阅端的主题必须完全一致大小写也计入差异。7. 常见问题与排查思路问题现象可能原因排查方式解决方案手机连不上Broker电脑防火墙拦截1883端口在电脑上用telnet或MQTT工具测试本机端口在防火墙中放行1883端口电脑能连手机连不上手机和电脑不在同一局域网对比两者的IP网段检查路由器设置让手机连接与电脑相同的Wi-Fi或使用内网穿透方案发布会话执行成功手机收不到消息手机没有订阅同一个主题在Broker Dashboard查看订阅关系统一发布端和订阅端的主题命名消息时有时无网络不稳定或QoS设置不当检查客户端日志中心跳状态适当调整keepAlive间隔按业务选择QoS 1App退到后台后收不到消息App进程被系统回收自建长连接断开查看Logcat观察进程是否被杀死改成前台服务并使用厂商推送通道弥补Android 13以上收不到通知栏消息未申请通知权限检查系统设置里的通知权限动态申请POST_NOTIFICATIONS权限多个客户端互相顶掉登录Client ID重复查看Broker连接列表使用唯一Client ID例如设备ID加时间戳MQTT连接成功后很快断开心跳间隔过长或网络NAT超时观察connectionLost触发时间缩短keepAliveInterval并实现自动重连关于Client ID这里要特别提醒MQTT协议要求同一个Broker下所有同时连接的客户端Client ID不能重复。如果两个设备使用了相同的Client ID后连接的那个通常会把前一个连接踢下线。很多新手在调试时会犯这个错误结果发现另一个手机突然连不上或者两个手机互相抢线。8. 生产环境最佳实践与工程建议8.1 客户端要设计自动重连和退避本文Demo里的连接逻辑只适合跑通链路不适合直接当生产代码用。真实项目中网络切换、弱网、Broker重启都会导致连接断开。客户端应该在connectionLost回调里设计重连机制并且使用指数退避策略。所谓指数退避就是第一次断开后1秒重连第二次断开后2秒第三次4秒最大间隔比如5分钟。这样做的好处是避免大量客户端在同一时刻疯狂重连压垮Broker。同时重连成功后要重新订阅之前订阅过的主题如果设计时使用的是isCleanSessionfalse部分Broker会保存订阅关系但为了稳妥业务代码重连后仍然应该订阅一次。8.2 服务端设计消息等级和去重机制推送系统经常会出现“用户收到同一条消息两次”的情况原因可能发生在网络超时重发、业务重复触发、客户端重连后重复接收等多个环节。服务端在设计时要做消息幂等最简单的做法是为每条业务消息生成一个全局唯一ID客户端收到后根据这个ID去重。在设计主题时建议按照业务语义划分。例如订单状态通知的主题可以设计成order/{orderId}/status而不是把消息全部塞到一个all主题里。主题设计得越有规律后续在Broker上做权限控制、监控和消息隔离就越方便。对于面向单个用户的设备常见的主题风格是push/{userId}/{deviceId}当多个设备属于同一个用户时既可以一个用户一个主题让所有设备订阅也可以按设备维度精确推送具体要看产品需求。如果用户有手机和Pad两台设备通常希望两台设备都能收到同一通知这时按用户维度订阅更合适如果只需要把控制指令发给特定设备就要用设备维度。8.3 安全与合规不能省测试阶段使用匿名访问问题不大但一旦进入真实环境必须关闭EMQX匿名接入并为每个设备或业务服务分配独立的用户名密码或证书。EMQX支持内置数据库认证也能对接MySQL、Redis等外部数据源。设备数量大时更推荐用ACL控制每个客户端可以订阅和发布的主题范围最小权限原则在这里同样适用。如果消息内容涉及隐私比如用户昵称、订单金额、具体地址要考虑对MQTT传输启用TLS加密。默认的MQTT 1883端口走的是明文TCP局域网里问题不大但到了公网环境消息可以被抓包看到。启用TLS意味着还需要管理和下发CA证书Android客户端连接时需要信任对应证书这一步会增加一定工作量但对生产环境来说是必要的。在合规方面申请通知权限时需要在代码里清晰说明用途不能触发系统权限弹窗后让用户困惑消息内容本身也要符合应用商店对垃圾信息和隐私政策的要求。无论使用厂商推送还是自建MQTT保存用户设备标识和推送历史时都要遵守数据收集的最小化原则。8.4 监控“到达率”而不是“发送成功率”很多自建推送系统上线之后服务端日志显示消息已经发布成功但用户反馈就是没收到。原因在于发布成功只代表消息到了Broker并不代表手机端收到了。要准确衡量推送链路健康状况至少要收集三类指标Broker到设备连接的在离线状态。设备确认收到的回执数量。用户真正点击通知栏的转化率。在没有业务回执的情况下服务端永远只能知道“发出去”了不知道“送到了”。在自建MQTT场景中可以让Android客户端收到消息后调一个HTTP接口或发布一条确认消息给Broker把设备接收回执上报到服务端。这样哪一步消息丢失就能在监控上直接看到。8.5 自建推送和厂商推送如何配合如果把自建通道和厂商推送结合起来使用常见的做法是当App正在前台或者刚退到后台不久自建MQTT通道还能正常工作时优先走自建通道这样实时性高且不依赖厂商当门禁系统检测到App进程已经失联或长时间离线时再调用厂商推送作为兜底。但这种双通道方案实现复杂度会明显上升产品初期如果没有强需求不建议一上来就做。更稳妥的方案是先用厂商聚合推送覆盖主要用户再在特定业务场景测试自建链路。比如内部员工App可以要求管理员把应用设为白名单同时用MQTT接收强实时性的仓库指令对外用户则使用厂商推送通知栏消息。两者定位不同所要维护的连接管理和消息链路也不同放在一起时要做好隔离。9. 消息送达之后的下一步推送消息到手机这个需求做到“手机能收到消息”只是第一步。在真实系统里你还要考虑消息生命周期、用户标签、通知渠道分组、点击跳转、海外用户接入、设备别名管理等一连串问题。每个问题展开都是一个独立专题。从实践角度看建议你按下面的路径逐步深入先用MQTT工具跑通服务端到Broker到手机的最小链路。再写一个最小Android工程把连接、订阅和日志输出跑通。再加入自动重连、离线判断、消息去重。最后才考虑接入厂商推送通道或做双通道策略。对于多数做业务系统的人来说真正的生产选择大概率是对外用户走厂商推送或聚合推送内部可控设备用自建MQTT。不要因为看到这篇帖子可以自建就认为自建方案适合所有产品那是把技术能力和产品需求混为一谈。先把消息通道的每个环节理解清楚再根据业务场景决定用哪套方案。到那时候无论是看厂商推送文档还是排查线上问题你都会比周围人更快找到关键点。