
简介这是面向后端开发者的消息推送平台完整工程实现基于Java技术栈统一接入邮件、短信、微信服务号、微信小程序、企业微信、钉钉等主流消息渠道解决企业内部多渠道消息发送分散、缺乏全链路追踪的问题。资源包共385个文件以290个Java源码文件为核心附有Spring Boot相关配置yml/properties/xml、数据库初始化SQL、RabbitMQ/Redis等中间件配置以及Dockerfile部署脚本压缩包大小18.7MB结构清晰便于二次开发。目前已有154人学习下载适合正在建设消息中台、需要统一消息发送与状态追踪的团队参考。通过该工程可快速掌握消息模板、渠道对接、生命周期管理等核心设计思路并可直接修改配置部署到生产环境。1. 为什么统一消息推送平台比多系统直连更值得依赖很多团队在没有统一消息推送平台之前邮件、短信、微信服务号、微信小程序、企业微信、钉钉都是各接各的业务方提一个通知需求开发就要去翻对应服务商的 API 文档。最后往往数据散落、发送状态不可查重试逻辑也五花八门。一个能统一收口所有消息类型、并对消息生命周期做全链路追踪的推送平台是业务规模上来之后的刚需。它向下屏蔽不同渠道提供商的协议差异向上给业务方提供一致的发送接口。适合公司内部有运营触达、用户通知、告警提醒等消息需求的团队也适合想自建消息中台的技术负责人。本文结合一份实际用过的中间件配置包聊一聊从路由设计到消息追踪落地的关键点。2. 平台基建从配置文件看懂消息路由骨架2.1 配置文件决定推送平台的稳定性边界一个以 RabbitMQ 为核心的推送平台配置文件不需要多但每一条都直接影响消息能否被安全、有序地投递。实际部署包里常见的配置清单会包含下面这些文件它们的职责要先分清楚文件作用关联组件rabbitmq.conf虚拟主机、监听端口、用户权限、心跳参数RabbitMQbroker.conf节点名、集群模式、数据目录、磁盘限制RabbitMQ10-default-guest-user.confguest 用户的默认访问策略生产环境必须约束RabbitMQ.erlang.cookieErlang 节点的认证凭证决定集群能否互通RabbitMQredis.conf消息去重、频控、实时状态缓存Redismysql.cnf消息明细、模板数据、全链路追踪的最终落库MySQLexample.csv批量导入接收方信息的样例文件数据导入我一般会最先处理10-default-guest-user.conf因为 RabbitMQ 默认的 guest 用户只能通过 localhost 登录。只要平台不是单机部署其他服务连接时就会直接报错user guest can only connect via localhost。常见的做法是屏蔽 guest 用户的远程权限单独建一个推送平台专用的账号再把该账号绑定到独立的虚拟主机上。2.1.1 rabbitmq.conf 的关键参数与选型新版 RabbitMQ 使用rabbitmq.conf替代原先的 Erlang 格式配置。下面是推送平台场景下最常调的一段配置listeners.tcp.default 5672 management.tcp.port 15672 vm_memory_high_watermark.relative 0.6 disk_free_limit.relative 1.5 heartbeat 60 default_vhost /austin default_user austin_push default_pass change_me default_permissions.configure .* default_permissions.read .* default_permissions.write .*逻辑说明vm_memory_high_watermark.relative 0.6表示内存用量达到物理内存的 60% 时RabbitMQ 会阻塞生产者连接这是避免节点被消息堆积打挂的最后防线。disk_free_limit.relative 1.5则是磁盘剩余空间低于该比例时暂停接收消息防止持久化队列把磁盘写满。heartbeat 60是服务端心跳超时秒数如果生产者或消费者超过 60 秒没有心跳连接会被主动回收这个值对跨机房或经过 NAT 的客户端特别重要。参数说明如果业务方有大量瞬时高峰vm_memory_high_watermark.relative建议下调到 0.40.5留出更多系统内存给消费端做业务处理。default_user和default_pass会在 RabbitMQ 初始化时直接创建账号不用再手工敲rabbitmqctl add_user。配置里的default_vhost可以按业务域拆分比如一个平台一套虚拟主机避免不同环境的队列互相干扰。2.2 Redis 和 MySQL 在推送链路里的分工Redis 在推送平台里承担的是短生命周期的状态处理最核心的是两件事消息去重和发送频控。以营销类短信为例同一用户 5 分钟内不能重复触达常规做法是在发送前执行SETNX send:user:{userId} 1 EX 300只有返回 1 时才允许继续投递。另一个用途是缓存实时状态比如消息是否入队、是否已推送到渠道这类数据只要求秒级一致Redis 的高性能正好匹配。MySQL 则保存消息主数据和模板数据。每一套推送模板都会绑定渠道类型、模板 ID、签名、内容结构。example.csv通常就是批量导入接收方名单的样例里面可能包含手机号、邮箱、微信 openid、钉钉 userId 等字段。平台读取 CSV 后会按照用户绑定的渠道拆分成具体的发送任务。推送场景下 redis.conf 的几个参数值得专门调一下maxmemory 4gb maxmemory-policy allkeys-lru appendonly yes appendfsync everysec逻辑说明maxmemory-policy allkeys-lru让 Redis 在内存不足时优先淘汰最久未被访问的 key。去重键和频控键都是短生命周期的即使被淘汰也不会影响未发送消息的完整性。appendfsync everysec是性能和数据安全性的折中每秒落一次盘极端情况下最多丢失 1 秒的去重状态相比之下不会造成大面积重复推送。这里要注意不要为了追求性能关闭 RDB 快照。因为平台重启后需要快速恢复去重键只靠 AOF 重放会比较慢保留 RDB 做冷启动加载可以明显降低发布期间的重复推送风险。2.3 消息队列如何承载多渠道消息类型邮件、短信、微信服务号、微信小程序、企业微信、钉钉这些渠道的消息模型差异很大。比如营销邮件对延迟不敏感可以走低优先级队列慢慢消费短信验证码延迟超过 10 秒就容易被用户投诉钉钉机器人和企业微信应用消息依赖回调确认必须处理削峰和重试。我见过比较稳的做法是每个渠道定义独立交换机再用不同 routing key 区分模板类型。业务方统一投递到交换机由 RabbitMQ 根据绑定关系分发到各渠道队列。这样如果某个渠道服务商暂时不可用该队列会积压却不影响其他渠道继续消费。使用 Spring WebSocket 向在线用户直接推送的场景也类似消息先进入实时网关队列再由 WebSocket 连接器消费避免推送动作阻塞业务请求线程。3. 统一发送接口与渠道路由的工程实现3.1 消息模型设计必须覆盖全渠道差异很多团队第一次做统一消息平台时容易把消息模型设计成“短信专用”或“邮件专用”后面接微信小程序、钉钉时才发现字段完全不够用。一个合理的消息模型至少要包含以下字段public class PushMessage { private String messageId; // 全局唯一消息ID追踪链路用 private String businessId; // 业务方自定义ID方便排查 private String receiverKey; // 手机号 / 邮箱 / openid / userId private ChannelEnum channel; // EMAIL, SMS, WECHAT_MP, WECHAT_MA, DINGTALK, WECOM private String templateId; // 模板ID private MapString, Object params; // 模板渲染参数 private String deduplicationKey; // 去重键 private Integer priority; // 0-10影响 RabbitMQ 优先级 }逻辑说明receiverKey不区分渠道是为了让上层业务不用关心用户到底用手机号还是 openid 接收。如果用户同时绑定手机和微信平台可以根据消息类型自动选择渠道如果业务方明确要求双渠道触达则在请求中显式传入渠道列表而不是隐式全部发送。businessId用来关联业务侧订单号或工单号遇到用户反馈“没收到短信”时通过它能快速反查到所有发送记录。参数说明priority在 RabbitMQ 中需要开启优先级队列才生效。不要把优先级当成万能药它只在消费者空闲时决定从哪个队列先取消息如果高优先级队列消费者已经被阻塞后面的消息依然会排队。3.2 基于策略模式的渠道分发器实现渠道分发是统一接口收敛 if-else 的关键常见做法是使用策略模式。每个渠道的发送器实现同一个接口在启动时按渠道枚举注册到 Map 中Component public class ChannelDispatcher { private final MapChannelEnum, MessageSender senderMap; public ChannelDispatcher(ListMessageSender senders) { this.senderMap senders.stream() .collect(Collectors.toMap(MessageSender::supportChannel, Function.identity())); } public void dispatch(PushMessage message) { MessageSender sender senderMap.get(message.getChannel()); if (sender null) { throw new UnsupportedChannelException(message.getChannel()); } sender.send(message); } }逻辑说明supportChannel()返回当前发送器支持的渠道枚举比如SmsSender.supportChannel()返回SMS。这样新增一个渠道比如未来要对接飞书只需要新增一个FeishuSender实现MessageSender接口分发器不用改动符合开闭原则。参数说明MessageSender接口通常包含send(PushMessage)和supportChannel()两个方法。实际工程里还会增加retry(PushMessage)让重试逻辑和首次发送分开重试时跳过频控和去重流程避免因为重试次数叠加导致频控误判。3.3 各渠道接入差异与限流约束真实接入渠道时会遇到各种边界条件列表里的差异几乎每个做推送平台的人都踩过渠道鉴权方式主要限流约束常见失败原因邮件SMTP 密码或 API Key按发信账号限制日额度接收方 DNS 解析失败、退信类型误判短信签名 密钥单号码每天次数、每分钟条数签名缺失或模板未审核微信服务号access_token模板消息每日主动推送次数token 缓存失效、模板 ID 不匹配微信小程序小程序 access_token订阅消息需用户授权一次性订阅拿服务号 token 去调小程序接口企业微信企业自建应用 Secret每应用消息量限制、IP 白名单未配置可信 IP返回 60020钉钉AppKey / AppSecret 加签机器人每分钟消息数限制回调加解密验签失败接入微信服务号和微信小程序时access_token 必须用 Redis 集中缓存同一个 token 不能两个应用混用。短信的签名和模板审核周期比邮件长平台侧需要做模板状态同步模板未通过时直接提示业务方而不是带着不合法模板去请求服务商。企业微信机器人相对简单但也要注意消息内容和文本格式Markdown 语法在部分客户端不渲染。钉钉则需要处理官方要求的加签流程使用HmacSHA256对时间戳和密钥做签名拼到 webhook URL 上签名过期时间通常不能回拨。4. 消息生命周期全链路追踪与失败重试4.1 用 messageId 串起发送链路全链路追踪的基础是一条消息从进入平台开始就拥有全局唯一messageId后续所有日志、Redis 状态、MySQL 记录都围绕它展开。常规做法是使用雪花算法生成因为它能保证趋势递增对 MySQL 聚簇索引更友好不像随机 UUID 那样会引起 B 树频繁分裂。一条消息至少要经过这些节点接收请求、参数校验、模板渲染、投递队列、渠道发送、异步回调。每个节点都输出带messageId的日志排查问题时直接执行一次关键字检索整条链路的耗时和失败原因都能串起来。4.2 消息状态在 Redis 与 MySQL 中的存储模型实时状态放 Redis最终状态落 MySQL这是避免推送记录表被高频更新的常见设计。Redis 使用 Hash 结构field 为messageIdvalue 为状态枚举MySQL 表结构设计时要注意检索效率CREATE TABLE push_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, message_id VARCHAR(64) NOT NULL, business_id VARCHAR(64) NOT NULL, receiver_key VARCHAR(128) NOT NULL, channel TINYINT NOT NULL, template_id VARCHAR(32) NOT NULL, status TINYINT NOT NULL COMMENT 0处理中 1成功 2失败 3超时, retry_times TINYINT DEFAULT 0, error_msg VARCHAR(512) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_message_id (message_id), KEY idx_business_channel (business_id, channel) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT消息推送记录表;逻辑说明status字段从 0 变更为 1 或 2通常不在消费端直接更新而是由回执处理逻辑负责。比如短信服务商回传状态后再更新push_record这样数据库的写入压力和回调频率解耦。retry_times单独存储比从日志反查更直观运营看板可以直接统计重试率。参数说明error_msg字段建议设置 512 长度能存下短信服务商返回的常见错误码和原因即可。不要存完整堆栈否则单条记录会膨胀到几 KB容易拖慢查询。如果需要存储回调原始报文建议单独建一张明细表。4.3 用 RabbitMQ 死信队列实现延迟重试失败消息重试不一定要写定时任务RabbitMQ 的 TTL 死信交换机就能完成延迟重试。为业务队列配置一个专门的重试交换机让发送失败的消息先进入带 TTL 的等待队列到期后再被投递回原队列。{ arguments: { x-dead-letter-exchange: austin.retry.exchange, x-dead-letter-routing-key: msg.retry, x-message-ttl: 60000 } }逻辑说明x-message-ttl设为 60000 毫秒消息会在当前队列里等待 60 秒然后被投递到austin.retry.exchange再按照msg.retry路由键回到业务队列。这种方案的优点是无需额外引入延迟队列中间件完全使用 RabbitMQ 原生能力。参数说明不同渠道的 TTL 应该分开设置。短信验证码重试周期短TTL 可以设为 1020 秒邮件对延迟不敏感TTL 可以拉到 5 分钟以上。如果所有渠道共用同一个 TTL某渠道服务商抖动时重试请求会在短时间内全部涌入增加下游压力。4.4 高频踩坑重复发送、回执丢失与队列堆积重复发送大多发生在回调还没到达时业务端进行了手动重试。解决办法是在发送前检查 Redis 中messageId是否存在消费端使用SETNX messageId做幂等控制只有第一次执行成功才允许调下游 API。回执丢失的根因往往是回调地址需要公网可达且回调报文格式不符合服务商要求。钉钉的加密回调尤其容易出问题验签和加解密必须是平台侧完全匹配才能收到回执否则服务商直接丢弃请求。遇到回调丢失时不要先重发消息而是先抓包确认回调请求是否真正到达服务器。队列堆积在短信渠道最常出现。监控中需要区分ready和unacknowledged如果ready一直上涨说明消费者消费速度跟不上如果unacknowledged持续不为 0则说明消费者线程在处理时被外部 IO 阻塞。常用的排查命令是rabbitmqctl list_queues name messages messages_unacknowledged参数说明当messages_unacknowledged长期保持较高数值时优先检查下游 API 的连接池大小和超时时间而不是单纯增加消费者线程否则会产生更多无效连接。5. 性能调优与推送验证的实用技巧5.1 针对高频推送的配置调整先调整 Redis。推送平台里单机 QPS 过万时appendfsync everysec依然是合理选择不要为了绝对安全改成always否则性能会断崖式下跌。RabbitMQ 侧则要根据队列数量和下游 API 的吞吐决定消费者数每个队列 35 个消费者比较常见太多消费者会导致与短信网关之间的连接数超限。注意块大小配置使用 Spring 的DefaultMessageListenerContainer时prefetchCount建议设置在 50 到 100 之间。prefetchCount太小会频繁向 RabbitMQ 拉取消息网络开销高太大则会导致消息堆积在消费端内存消息丢失时恢复困难。5.2 验证消息是否真正送达的三种方式第一种查平台自己的push_record表。状态为成功只能代表平台已把消息推给服务商不代表用户真实收到但可以确认平台侧没有异常。第二种用测试手机号或测试邮箱完整走一遍流程短信可以抓包验证模板渲染结果邮件则要检查是否被运营商放入垃圾箱优先排查 SPF 和 DKIM 配置。第三种用 RabbitMQ 队列指标做对照rabbitmqctl list_queues name messages messages_unacknowledged dead_letter逻辑说明dead_letter字段能看到转入死信队列的消息数。如果该值持续增加说明下游渠道发送失败率偏高需要根据错误码区分是临时性失败还是永久性失败。临时性失败可以继续走重试队列永久性失败则要直接标记为最终失败否则会无限循环。5.3 一个实用技巧回调处理与发送队列解耦之前处理过一次短信回执事故短信网关回调并发量一高负责回执处理的线程池被打满导致消息状态迟迟不能更新进而触发大量重试。后来把回调处理拆成独立队列网关回调先落 Redis再投递到回执处理队列由专门的消费者异步更新 MySQL发送队列和回执队列不再互相影响。这个思路同样适用于企业微信、钉钉这类带有异步回执的渠道。调优时还要注意邮件发送服务商的 API 超时时间通常较长消费线程会被占住很久因此邮件渠道的消费者数量要比短信渠道少否则会占用大量线程资源。上线前对每个渠道做一次压测把队列积压阈值和告警通道都配好再投放生产流量。本文还有配套的精品资源点击获取