iOS推送通知完整指南:一个DBA出身的后端开发的血泪踩坑实录
上周五晚上十一点半,我还在公司对着一行 Python 脚本发呆。窗外成都的夜色早已沉寂,茶水间只剩我一人,咖啡凉了三杯——没错,又是那种“产品经理说下周上线”的经典场景。
我是老K,一个从 DBA 转型做后端开发的“数据洁癖患者”。说白了,就是看不得慢 SQL、冗余字段和没索引的表。如今虽然天天写 Go 和 Python,但骨子里还是那个半夜三点爬起来查慢查询日志的数据库老狗。最近我们团队接了个新需求:给 App 加推送通知,支持用户行为触发+定时提醒。听起来简单?呵,等你被 Apple 的 APNs 证书搞到怀疑人生、被测试同学疯狂@说“收不到推送”时,你就懂什么叫“前端五分钟,后端两星期”。
今天这篇文章,不讲理论堆砌,就用我们项目里真实踩过的坑、熬过的夜、骂过的 PM(开玩笑的),带你走通 iOS 推送通知的完整链路。顺便,我会把“产品”和“爬虫”这两个关键词,自然地揉进实战中——毕竟,没有爬虫的数据,哪来精准的产品推送?
需求背景:产品要“聪明”的推送,不是垃圾短信
事情得从上个月说起。产品经理小李(对,就是那个每次都说“这个功能很简单”的小李)拿着 PRD 找我:“老K,我们要做个性化推送!比如用户看了某商品但没下单,24 小时后推个优惠券;或者他连续三天没打开 App,就发个‘想你了’。”
我第一反应是:这不就是典型的 用户行为触发 + 定时任务 吗?但转念一想——iOS 推送可不是微信公众号,随便发就行。Apple 对推送有严格限制:必须通过 APNs(Apple Push Notification service),设备 Token 有效期不定,还有审核风险……更别提用户点了“不允许通知”之后,你连 Token 都拿不到。
更要命的是,产品要求实时性。比如用户刚退出购物车页面,5 分钟内就要收到提醒。这意味着后端不能只靠定时任务扫库,还得监听用户行为事件流。
于是,我的架构脑图迅速展开:
- 前端(iOS)注册推送权限,获取 deviceToken
- 把 token + 用户 ID 上报到后端
- 后端监听用户行为(比如“加入购物车”、“浏览商品”)
- 根据规则引擎判断是否触发推送
- 调用 APNs 发送通知
看起来很美?现实很快打了我脸。
第一坑:deviceToken 不是字符串,是 Data!
周一早上,iOS 同学给我发了个 Slack 消息:“老K,deviceToken 已上报,格式是十六进制字符串,你看下。”
我一看数据库里的 device_token 字段,存的是类似 a1b2c3d4... 的字符串。心想:OK,没问题。
结果当天下午,测试同学在 TestFlight 装了包,死活收不到推送。我本地用 curl 调 APNs 模拟发送,返回:
{"reason":"BadDeviceToken"}
???我反复核对 token,确认无误啊!
深夜翻 Apple 官方文档才恍然大悟:deviceToken 在 iOS 端是 Data 类型,不是 String! 很多开发者直接用 token.description 转成字符串,会带上 < > 符号和空格,比如:
< a1b2 c3d4 e5f6 ... >
而 APNs 要求的是 纯十六进制、无空格、无符号 的字符串。
解决方案?让 iOS 同学改代码:
// 正确做法:把 Data 转为纯 hex string
func hexString(from data: Data) -> String {
return data.map { String(format: "%02x", $0) }.joined()
}
// 在 didRegisterForRemoteNotificationsWithDeviceToken 中调用
let deviceTokenString = hexString(from: deviceToken)
// 上报 deviceTokenString 到后端
那一刻我真想冲进 iOS 办公区问一句:“你们是不是都这么干的?”——后来才知道,这是 iOS 新手经典误区,连 Stack Overflow 上都有上百个类似问题。
第二坑:APNs 证书 vs Token 认证,选哪个?
搞定 token 格式后,我以为可以高枕无忧了。结果部署到生产环境,又炸了。
原来我们用的是 APNs 证书认证(Certificate-based),需要每年续期 .p12 文件。运维同学去年双11前忘了续,导致大促期间推送全挂,被老板骂到自闭。
今年我决定切换到 Token 认证(JWT-based)。好处很明显:
- 无需管理证书,用密钥文件(
.p8)即可 - 有效期长达一年(实际可长期使用)
- 支持多个服务共用同一密钥
配置也不复杂。在 Apple Developer Account → Keys → Create a New Key,勾选 Apple Push Notifications service (APNs),下载 .p8 文件。
后端用 Go 写了个简单的 APNs 客户端(我们后端主力语言是 Go):
// 使用 github.com/sideshow/apns2 库
import "github.com/sideshow/apns2"
client := apns2.NewClient(apns2.Key{
AuthKey: readP8File("AuthKey_XXX.p8"),
KeyID: "YOUR_KEY_ID",
TeamID: "YOUR_TEAM_ID",
})
payload := &apns2.Notification{
DeviceToken: "a1b2c3d4...", // 从数据库取出
Payload: []byte(`{"aps":{"alert":"Hi from backend!"}}`),
}
res, err := client.Push(payload)
if err != nil || res.StatusCode != 200 {
log.Errorf("Push failed: %v, status: %d", err, res.StatusCode)
}
关键点:.p8 文件不要提交到 Git!我们把它放在 K8s Secret 里,通过 volume mount 挂载。
切换后,稳定性大增。再也没人半夜打电话说“推送挂了”。
第三坑:产品逻辑 + 爬虫数据 = 精准推送
现在基础链路通了,但产品要的是“智能推送”。比如:“用户浏览了 iPhone 15,但没买,24 小时后推降价提醒”。
问题来了:我们怎么知道用户看了什么商品?
前端埋点?可以,但数据延迟高、可靠性差。
我们选择了一个更“DBA 式”的方案:用爬虫监控商品价格变动,结合用户行为日志,做离线计算。
具体流程:
- 用户在 App 浏览商品 A → 埋点上报
view_item事件(含 item_id) - 后端将事件写入 Kafka
- Flink 作业消费 Kafka,聚合用户最近 7 天浏览记录,存入 ClickHouse(我最爱的 OLAP 数据库)
- 爬虫服务每天凌晨抓取竞品平台(比如京东、拼多多)的商品价格,存入 MySQL
- 定时任务(CronJob)扫描 ClickHouse 中“看过但未购买”的用户,关联爬虫数据,若发现降价 ≥10%,则触发推送
这里,“爬虫”成了推送系统的外部数据源,而“产品”定义了推送规则。两者结合,才实现真正的个性化。
举个例子,我们的推送规则表长这样:
| rule_id | trigger_event | condition | push_template |
|---|---|---|---|
| 1 | view_item | item_price_drop >= 10% in 24h | "您关注的 {{item_name}} 降价啦!" |
| 2 | cart_abandon | no purchase in 1h | "购物车有宝贝在等你~" |
后端根据 rule_id 动态生成推送内容。注意:推送内容必须符合 Apple 审核规范,不能包含诱导点击、虚假信息。我们曾因模板写“限时抢购!!!”被拒审,改成“价格更新”才过。
性能优化:别让推送拖垮数据库
作为 former DBA,我对性能敏感得像过敏体质。初期方案是:每次用户行为事件到来,直接查 MySQL 看是否满足推送条件。
结果压测时,MySQL CPU 飙到 90%。原因?user_behavior 表没建复合索引,且频繁 join product 表。
优化思路:
- 读写分离:行为写入主库,推送判断读从库
- 缓存热点数据:商品价格、用户推送开关状态,用 Redis 缓存
- 异步处理:事件先入 Kafka,由消费者批量处理,避免实时查库
最关键的是加了这个索引:
-- user_behavior 表
ALTER TABLE user_behavior
ADD INDEX idx_user_item_time (user_id, item_id, event_time DESC);
查询从 500ms 降到 8ms。那一刻,我仿佛又回到了当年调优慢 SQL 的快乐时光。
App Store 审核避坑指南
去年我们有个版本被拒,理由是:“App 请求推送权限过于激进”。
原来我们在启动页就弹 requestAuthorization,Apple 认为这是 bad user experience。正确做法是:
- 先教育用户(比如引导页说明“开启通知不错过优惠”)
- 在合适时机请求(比如用户完成首次下单后)
SwiftUI 示例:
import UserNotifications
func requestNotificationPermission() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
// 在用户完成关键操作后调用
Button("完成订单") {
submitOrder()
// 延迟 1 秒请求权限,避免打断流程
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
requestNotificationPermission()
}
}
另外,务必处理用户拒绝权限的情况。可以在设置页引导用户手动开启:
// 检查通知是否开启
UNUserAssistant.current().getNotificationSettings { settings in
if settings.authorizationStatus != .authorized {
// 提示用户去设置开启
showAlert("请在设置中开启通知权限")
}
}
总结:推送不是功能,是体验
折腾了三周,终于搞定。现在每天稳定发送 50w+ 条推送,送达率 98.7%,产品同学逢人就夸“老K 牛逼”(其实主要是 iOS 和运维兄弟给力)。
回头看,iOS 推送看似简单,实则涉及:
- 前端权限管理与 token 处理
- 后端高可靠消息通道(APNs)
- 产品规则与数据驱动(爬虫+行为日志)
- 数据库与缓存性能优化
- App Store 审核合规
作为一个从 DBA 转型的人,我最大的体会是:技术栈可以变,但对数据准确性和系统稳定性的执念,永远不变。推送不是发条消息就完事,它背后是用户信任、产品策略和工程能力的综合体现。
下次如果你也被 PM 说“推送很简单”,不妨把这篇文章甩给他,然后泡杯茶,坐等他沉默三分钟。
对了,成都今晚下雨,适合写代码。我先去 fix 一个 bug 了——据说用户凌晨三点收到推送会卸载 App,得赶紧加个“勿扰时段”过滤。
(全文完)

评论 0