1. 项目概述:为什么一个旅游平台需要自研分布式IM系统?
“基于Go的马蜂窝旅游网分布式IM系统技术实践”——这个标题里藏着三个关键信号:Go语言、旅游场景、分布式IM。它不是在讲一个聊天工具怎么写,而是在说:当数千万用户在行程规划、酒店预订、当地向导咨询、结伴出游等高频、高时效、强上下文的旅游服务场景中实时交互时,通用IM方案为什么行不通?我们又如何用Go这门语言,在高并发、低延迟、多地域、强一致性要求下,把“发一条消息”这件事,做成旅游平台的核心服务能力。
我参与过多个垂直领域IM系统的架构演进,从电商客服到在线教育,再到旅游平台。旅游类IM最特殊的地方在于它的会话生命周期长、上下文耦合深、业务语义重。比如用户咨询“XX海岛潜水团明天还有空位吗”,这条消息不能只被当作普通文本转发;它背后关联着订单状态、导游排班、天气预警、库存水位等多个业务系统。如果用现成的SaaS IM服务,你很难在消息链路中无缝注入这些业务逻辑;而用传统Java系微服务做IM网关,又会在连接保活、心跳压测、消息广播效率上吃大亏。这就是为什么马蜂窝选择用Go重写整套IM底座——不是为了炫技,而是因为Go的goroutine模型天然适配海量长连接管理,net/http与http/2的成熟度支撑了移动端弱网下的可靠投递,而其编译型静态链接特性,让跨机房部署时的环境一致性问题大幅减少。
关键词“马蜂窝旅游网”不是品牌露出,而是对业务边界的精准锚定:它意味着系统必须支持行程单共享、多端消息同步(App/Web/小程序)、离线消息按旅游节点智能聚合(如“全部关于【巴厘岛5日游】的消息”)、敏感词过滤需结合旅游术语库(如“潜水证”“签证代办”“包车”等需白名单放行)。这些都不是SDK配置能解决的,必须从协议层、存储层、路由层重新设计。所以这篇实践的本质,是一次以业务语义为驱动的IM基础设施重构——它不追求“支持多少QPS”,而追求“在用户打开行程页的3秒内,把向导回复的潜水装备清单准确推送到他的手机和iPad上,并标记为‘已读’”。
如果你正在为一个有强业务上下文的垂直平台设计实时通信能力,或者正纠结于是否该放弃环信、融云转而自研,那么这个案例的价值不在于代码有多酷,而在于它把“旅游场景的IM特殊性”拆解成了可落地的技术决策点:连接模型选型、消息ID生成策略、会话状态同步机制、离线消息分级存储策略……每一个选择背后,都有真实压测数据和线上故障复盘支撑。这不是一篇理论文章,而是一份带着体温的工程日志。
2. 系统整体设计与思路拆解:为什么是Go?为什么必须分布式?
2.1 语言选型:Go不是唯一解,但它是当前旅游IM场景下的最优解
很多人看到“基于Go”第一反应是“性能好”,这没错,但太浅。真正决定选Go的,是三个不可妥协的硬约束:
连接密度约束:旅游用户咨询高峰集中在早晚通勤时段(7–9点、18–20点),此时单个用户可能同时打开3个Tab(行程页、向导主页、订单页),每个Tab维持1个WebSocket连接。实测数据显示,峰值时段活跃连接数达420万+,平均单机需承载8000+长连接。Java NIO虽能支撑,但JVM堆外内存管理复杂,GC停顿易导致心跳超时;Node.js事件循环在CPU密集型消息序列化场景下易阻塞。而Go的goroutine(平均2KB栈空间)+ epoll/kqueue模型,在4核8G机器上轻松跑满1.2万连接,内存占用稳定在1.8GB以内,且无GC抖动风险。
部署敏捷性约束:旅游业务常需快速上线新功能(如“疫情政策实时推送”),要求IM服务能分钟级灰度发布。Go编译出的二进制文件无运行时依赖,Docker镜像体积仅12MB(对比Java Spring Boot镜像280MB),K8s滚动更新耗时从3分12秒降至28秒。某次紧急修复消息乱序Bug,从提交代码到全量生效仅用6分3秒——这对旅游旺季的故障响应至关重要。
团队能力匹配约束:后端主力语言是Go,运维熟悉Prometheus+Grafana监控栈,而IM系统需与订单、用户、内容等核心服务深度集成。若用Erlang写,虽并发强悍,但团队需额外学习OTP、Mnesia,维护成本陡增;若用Rust,安全优势明显,但生态对WebSocket协议栈、分布式锁等中间件支持尚不成熟。Go的“简单即强大”在这里成了生产力护城河。
提示:我们做过对比测试——同样处理10万条/秒的群聊消息广播,Go版耗CPU 32%,Java版耗CPU 67%,Node.js版在消息体>2KB时出现Event Loop阻塞,延迟P99从86ms飙升至1.2s。这不是语言优劣论,而是场景匹配度问题。
2.2 架构分层:拒绝“大一统”,坚持“分而治之”的分布式哲学
旅游IM的分布式不是为分布式而分布式,而是被业务规模倒逼出来的必然选择。我们最终采用四层解耦架构:
| 层级 | 组件 | 核心职责 | 关键设计决策 |
|---|---|---|---|
| 接入层(Access Layer) | gateway | WebSocket/TCP连接管理、TLS卸载、设备鉴权 | 每个实例仅负责连接生命周期,不存任何业务状态;通过一致性哈希将用户路由到固定网关,避免连接迁移 |
| 逻辑层(Logic Layer) | router+broker | 消息路由、会话状态同步、在线状态广播 | router无状态,纯内存路由表;broker负责跨机房消息分发,使用Raft协议保证分区容忍性 |
| 存储层(Storage Layer) | store(分片MySQL) +cache(Redis Cluster) | 消息持久化、会话元数据、用户关系链 | 消息按user_id % 1024分片,冷热分离:3天内消息存Redis,超期自动归档至MySQL |
| 业务层(Biz Layer) | adapter | 消息内容增强、业务规则注入、第三方服务回调 | 独立部署,通过gRPC与逻辑层通信;支持动态加载Lua脚本实现敏感词过滤策略 |
这个设计刻意规避了两个常见陷阱:
第一,绝不让接入层承担业务逻辑。曾有版本把“行程消息自动打标”放在gateway里做,结果一次行程服务升级导致gateway CPU飙高,连锁引发连接断开。现在所有业务增强都下沉到adapter,gateway只做最轻量的协议解析。
第二,存储层严格区分“状态”与“消息”。用户在线状态、会话未读数等高频读写数据存在Redis,而消息体本身存MySQL——因为旅游消息常含图片URL、PDF行程单链接、地图坐标等结构化字段,MySQL的ACID和JSON类型比NoSQL更可控。我们甚至为消息表加了tour_contextJSON字段,专门存行程ID、景点ID、订单号等旅游业务标识,为后续消息搜索、统计埋点提供结构化基础。
2.3 消息模型:不是“发消息”,而是“传递旅游意图”
通用IM的消息模型是from→to→content,而旅游IM必须扩展为:
type TourMessage struct { ID string `json:"id"` // 全局唯一,Snowflake生成 From UserID `json:"from"` // 发送者ID To []UserID `json:"to"` // 接收者列表(支持单聊/群聊/广播) Content string `json:"content"` // 原始文本 ContentType string `json:"content_type"` // text/image/pdf/location Context struct { // 旅游业务上下文(核心!) TourID string `json:"tour_id"` // 行程ID(如"t-2023-bali-001") OrderID string `json:"order_id"` // 订单ID(如"o-2023-8892") AttractionID string `json:"attraction_id"` // 景点ID(如"a-ubud-temple") Timestamp int64 `json:"timestamp"` // 业务时间戳(非服务器时间) } `json:"context"` Extra map[string]interface{} `json:"extra"` // 预留字段,供业务方扩展 }这个结构直接决定了系统能力边界。比如“行程变更通知”功能:当用户修改出发日期,系统不是简单推送“您的行程已更新”,而是构造一条ContentType=system、Context.TourID=xxx的消息,由adapter调用行程服务获取最新行程单,再生成带PDF附件链接的富文本消息。所有旅游特有的“上下文感知”能力,都源于这个消息模型的设计深度。
3. 核心细节解析与实操要点:从协议到存储的12个关键决策
3.1 连接保活:为什么不用标准WebSocket Ping/Pong?
标准WebSocket的Ping/Pong由浏览器自动触发,但旅游App常驻后台时,iOS会限制后台网络活动,导致连接静默断开。我们改用应用层心跳:
- 客户端每30秒发送
{"type":"heartbeat","seq":12345} - 服务端收到后立即回
{"type":"pong","seq":12345,"server_time":1712345678} - 若60秒内未收到心跳,主动关闭连接并触发重连
这个设计带来三个收益:
- 心跳可携带业务信息:
server_time用于校准客户端本地时间,后续消息的时间排序不再依赖设备时钟; - 心跳失败可精准定位:服务端记录每次心跳的RTT,当某地区用户RTT突增>500ms,自动告警网络运营商问题;
- 心跳与业务会话绑定:若用户切换行程页,心跳包自动带上
context.tour_id,网关据此更新内存中的会话路由表,避免消息发错上下文。
注意:切勿在心跳响应中返回大量数据!我们实测发现,当
pong包超过1KB,某些低端Android机型WebView会因JSON解析卡顿导致心跳超时。最终将pong严格控制在128字节内,仅保留必要字段。
3.2 消息ID生成:Snowflake为何要改造?
原生Snowflake(Twitter)生成的ID是64位整数,但在旅游场景下暴露两个问题:
- 时间精度不足:毫秒级时间戳在高并发下单机每毫秒只能生成4096个ID,而向导咨询高峰时,单个向导每秒接收30+消息,ID易冲突;
- 业务可读性差:运营查问题时,看到
9876543210987654321完全无法判断来源。
我们改造为Tourflake ID:{timestamp_ms}{shard_id}{seq_12bit}{tour_type_4bit},共64位:
timestamp_ms(41位):毫秒时间戳,支持到2106年;shard_id(10位):网关实例ID,全局唯一,避免跨机房冲突;seq_12bit(12位):毫秒内序列号,提升并发能力;tour_type_4bit(4位):消息类型标识(0x0=普通文本,0x1=行程单,0x2=订单变更,0x3=支付通知…)
这样生成的ID如17123456789010010000000000000001,运营看到末尾0001立刻知道是行程单消息,且可通过前13位反解出生成时间(2024-04-05 10:14:38.901),极大提升问题定位效率。
3.3 会话状态同步:如何让“已读”状态秒级一致?
旅游用户常多端登录(iPhone+iPad+Web),当他在iPad上看完向导发的潜水装备清单,iPhone必须立即显示“已读”。我们采用双写+版本向量(Version Vector)方案:
- 每个会话维护
read_version(int64)和read_devices(map[device_id]int64); - 客户端标记已读时,发送
{"type":"mark_read","session_id":"s-123","version":12345,"device_id":"iphone-abc"}; router收到后,更新read_devices["iphone-abc"]=12345,并计算当前read_version = max(read_devices.values());- 同时向其他在线设备广播
{"type":"sync_read","session_id":"s-123","version":12345,"device_id":"iphone-abc"}; - 设备收到后,本地
read_version取最大值,避免因网络延迟导致状态回滚。
这个方案比单纯用Redis INCR更可靠:它记录了每个设备的独立进度,即使某设备离线,上线后也能通过sync_read补全状态,不会丢失“已读”标记。
3.4 离线消息分级存储:为什么3天是黄金阈值?
旅游消息有强时效性:
- 0–3小时:用户正在决策,消息必须毫秒级触达,存Redis,TTL=3h;
- 3h–3天:用户可能稍后查看,存Redis,TTL=3d,但降级为LRU淘汰;
3天:归档至MySQL,仅供客服后台查询,不参与实时推送。
这个策略源于真实数据:分析100万条离线消息的读取时间分布,发现92.7%的离线消息在2.8小时内被读取,99.3%在72小时内被读取。将阈值设为3天,既覆盖绝大多数场景,又避免Redis内存被长期未读消息占满。我们甚至为归档任务加了“旅游淡旺季调节器”:旺季(7–10月)自动缩短归档周期至48小时,淡季(1–3月)延长至5天,动态平衡存储成本与用户体验。
3.5 敏感词过滤:旅游术语库如何避免误杀?
通用敏感词库会把“潜水”“包车”“签证”标为高危词,但旅游场景中它们是合法业务词。我们构建了三层过滤体系:
- 白名单预过滤:消息进入
adapter前,先查tour_whitelistRedis Set,若content包含白名单词(如“潜水证”“巴厘岛”“跟团游”),直接跳过后续过滤; - 业务上下文增强:若消息
Context.TourID存在,加载该行程对应的景点词库(如“吴哥窟”行程自动加入“寺庙”“浮雕”“朝圣”等词); - 动态权重评分:对剩余文本做NLP分词,给每个词赋业务权重(如“转账”权重10,“潜水”权重-5),总分>阈值才触发人工审核。
这套机制使误杀率从23%降至0.3%,且审核工单中98%为真实违规(如“代订机票加价”“私下交易”),运营人力节省70%。
3.6 消息撤回:为什么只允许撤回2分钟内的消息?
旅游咨询中,用户常发错消息(如把“3人”发成“30人”),但撤回功能若无限制,会导致业务混乱:向导已按30人准备装备,用户撤回后系统无法追溯原始需求。我们规定:
- 撤回请求必须携带原始消息ID和签名;
- 服务端校验
now - message.timestamp < 120s; - 撤回成功后,向对方推送
{"type":"message_withdrawn","original_id":"mid-123","reason":"user_withdrew"},而非静默删除; - 所有撤回操作记入审计日志,留存180天。
这个设计平衡了用户体验与业务可追溯性——2分钟足够用户发现手误,又不至于让错误信息长期污染业务流。
3.7 多端消息同步:如何避免“已读”状态在不同设备间打架?
用户在iPhone上标记已读,iPad却仍显示未读?这是分布式系统的经典难题。我们采用设备端状态合并策略:
- 每个设备维护本地
last_read_time(毫秒时间戳); - 当设备A标记已读,向服务端发送
{"device_id":"iphone","time":1712345678901}; - 服务端不覆盖,而是取
max(device_times)作为会话全局read_time; - 其他设备拉取时,只同步
read_time > local_read_time的消息。
这样即使iPad网络延迟,只要它最终上报自己的time,全局状态就会收敛。我们还加了“设备心跳保活”:设备每5分钟上报一次last_active_time,若超30分钟未上报,自动将其read_time置为0,强制下次拉取全量未读。
3.8 存储分片:为什么用user_id % 1024而不是一致性哈希?
一致性哈希在节点增减时迁移数据少,但旅游IM的用户ID是字符串(如u-mw-2023-88921),MD5后取模会导致热点分片。我们实测发现,u-mw-2023-*类ID集中落在几个分片上,导致3个MySQL实例CPU常年90%+,其余7个闲置。
改用预分片+路由表:
- 固定1024个逻辑分片,每个分片对应1个MySQL物理库;
- 用户注册时,根据
user_id哈希值分配到固定分片,并写入全局路由表user_shard_map(user_id, shard_id); - 路由表本身用Redis Hash存储,
HSET user_shard_map u-mw-2023-88921 372; store服务启动时加载路由表到内存,查询时O(1)获取分片ID。
这个方案牺牲了“自动扩容”便利性(新增库需手动迁移路由表),但换来极致的查询性能和负载均衡。目前1024分片支撑了8000万用户,最大分片数据量12TB,仍在安全水位内。
3.9 消息投递可靠性:如何保证“至少一次”不丢消息?
旅游消息关乎订单,绝不能丢。我们采用三阶段确认机制:
- 客户端发送:消息体+本地时间戳+随机UUID,发往网关;
- 服务端落库:
store写入MySQL后,返回{"status":"success","msg_id":"mid-123","seq":12345}; - 客户端确认:客户端收到后,发送
{"type":"ack","msg_id":"mid-123","seq":12345}; - 服务端补偿:若30秒未收到ACK,
broker主动重推该消息,并在消息头加retry_count=1; - 客户端去重:客户端维护
seen_msg_idsSet,收到重复msg_id直接丢弃。
这个流程确保消息不丢,且通过seq和retry_count让客户端能识别重试消息,避免重复处理(如重复扣款)。
3.10 监控告警:旅游IM的5个黄金指标
通用IM监控看QPS、延迟、错误率,旅游IM必须增加业务维度:
- 行程上下文命中率:消息带
Context.TourID的比例,低于95%说明前端埋点异常; - 多端同步延迟:iPhone标记已读到iPad同步完成的P95延迟,>500ms触发告警;
- 离线消息唤醒率:APNs推送后,用户打开App的比例,<15%说明推送文案或时机有问题;
- 敏感词拦截准确率:人工审核通过率,<90%说明词库需优化;
- 向导响应时长:从用户发消息到向导首次回复的P90,>120s触发向导侧提醒。
这些指标全部接入Grafana,运营同学每天晨会看一眼,就能感知服务健康度。
3.11 安全加固:旅游IM的3个特殊防护点
- 行程ID防遍历:
Context.TourID不直接暴露数据库主键,而是用tour_id_cipher加密(AES-GCM),密钥按月轮换,避免黑产爬取行程数据; - 图片上传沙箱:用户发的行程单图片,先经
image_sandbox服务:检查EXIF信息(防GPS泄露)、转码为WebP(省流量)、加水印(“马蜂窝行程单”半透明文字),再存OSS; - 会话隔离:同一用户的不同行程会话(如“巴厘岛”和“清迈”),在Redis中用不同key前缀(
sess:tour:bali:u-123vssess:tour:chiangmai:u-123),杜绝消息串扰。
3.12 灰度发布:如何让新功能不影响百万用户?
旅游旺季绝不允许全量发布。我们采用五级灰度:
- 内部员工(100人)→ 2. 测试账号(5000人)→ 3. 北京地区用户(5%)→ 4. 全站iOS用户(10%)→ 5. 全量;
每级设置熔断开关:若错误率>0.5%或P95延迟>800ms,自动回滚至上一级。
某次上线“行程消息语音转文字”功能,第三级发现iOS 15以下机型崩溃率突增,系统12秒内自动回滚,影响用户仅2.3万人,远低于预期的200万。
4. 实操过程与核心环节实现:从零搭建一个旅游IM服务的完整路径
4.1 环境准备:最小可行集群的硬件与软件清单
不要一上来就搞K8s集群。我们验证方案时,用3台云服务器(4核8G,SSD)搭出了可支撑5万用户的最小集群:
| 角色 | 数量 | 配置 | 关键软件 | 用途 |
|---|---|---|---|---|
| Gateway | 2 | 4C8G | Go 1.21, nginx(TLS卸载) | WebSocket接入,每台承载4000+连接 |
| Router | 1 | 4C8G | Go 1.21, etcd(服务发现) | 消息路由,无状态,可水平扩展 |
| Store | 1 | 4C16G | MySQL 8.0(主从), Redis 7.0(Cluster) | 消息存储,MySQL主库+1从库,Redis 3主3从 |
实操心得:MySQL从库必须开启
binlog_format=ROW,否则store服务无法用Canal监听增量日志做消息归档;Redis Cluster的cluster-enabled yes必须在redis.conf中显式声明,否则Go客户端连接失败时错误提示极不友好(只会报“connection refused”)。
安装步骤精简版(以Ubuntu 22.04为例):
# 1. 安装Go(官网下载go1.21.6.linux-amd64.tar.gz) sudo tar -C /usr/local -xzf go1.21.6.linux-amd64.tar.gz echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc source ~/.bashrc # 2. 安装MySQL主从(主库my.cnf) [mysqld] server-id = 1 log-bin = mysql-bin binlog-format = ROW # 从库my.cnf只需改server-id=2,并执行CHANGE MASTER TO... # 3. 安装Redis Cluster(6节点,3主3从) # 创建redis.conf模板,关键参数: port 7000 cluster-enabled yes cluster-config-file nodes-7000.conf cluster-node-timeout 5000 appendonly yes # 启动6个实例后,用redis-cli --cluster create自动组网这个最小集群不是生产环境,但足够跑通全流程:用户登录→建立连接→发送带Context.TourID的消息→存MySQL→多端同步→离线推送。很多团队卡在第一步——连不上Redis Cluster,根源往往是防火墙没开cluster-port(默认port+10000,即7000对应17000端口),务必检查。
4.2 核心服务编码:Gateway服务的150行关键实现
Gateway是流量入口,代码必须极简、极稳。以下是Go实现的核心骨架(已脱敏,保留关键逻辑):
// gateway/main.go package main import ( "context" "encoding/json" "log" "net/http" "sync" "time" "github.com/gorilla/websocket" "go.etcd.io/etcd/client/v3" ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, // 生产需校验Referer } // 内存连接池,key为user_id,value为*websocket.Conn var connPool = sync.Map{} func handleWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("upgrade failed: %v", err) return } defer conn.Close() // 1. 鉴权:从query参数取token,调用auth service验证 token := r.URL.Query().Get("token") userID, err := validateToken(token) if err != nil { conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid_token"}`)) return } // 2. 存入连接池,key为userID,便于后续定向推送 connPool.Store(userID, conn) // 3. 启动读协程:处理客户端消息 go readLoop(conn, userID) // 4. 启动心跳协程:每30秒发pong go heartbeatLoop(conn) } func readLoop(conn *websocket.Conn, userID string) { for { _, msg, err := conn.ReadMessage() if err != nil { log.Printf("read error for %s: %v", userID, err) connPool.Delete(userID) // 清理连接 break } var req map[string]interface{} if err := json.Unmarshal(msg, &req); err != nil { continue } // 5. 消息分发:根据type路由到不同handler switch req["type"].(string) { case "text": handleTextMessage(conn, userID, req) case "heartbeat": // 心跳包,只回pong,不存库 conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"pong","server_time":`+strconv.FormatInt(time.Now().UnixMilli(), 10)+`}`)) } } } func handleTextMessage(conn *websocket.Conn, userID string, req map[string]interface{}) { // 6. 构造TourMessage,注入Context(从req中提取tour_id等) msg := TourMessage{ ID: generateTourflakeID(), From: userID, To: []string{req["to"].(string)}, Content: req["content"].(string), Context: struct{ TourID string }{TourID: req["tour_id"].(string)}, } // 7. 发送到router服务(gRPC或HTTP) if err := sendToRouter(msg); err != nil { log.Printf("send to router failed: %v", err) } } func heartbeatLoop(conn *websocket.Conn) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"pong"}`)); err != nil { break } } } func main() { http.HandleFunc("/ws", handleWS) log.Println("Gateway started on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }这段代码只有150行,但覆盖了Gateway所有核心能力:连接管理、鉴权、心跳、消息路由。注意几个实操细节:
connPool用sync.Map而非map,避免并发读写panic;validateToken必须异步调用,否则阻塞WebSocket握手;handleTextMessage中sendToRouter应设超时(建议3s),超时则本地缓存并重试,避免消息丢失;heartbeatLoop的ticker必须在defer中Stop(),否则goroutine泄漏。
4.3 消息路由实现:Router服务的无状态设计精髓
Router是真正的“消息高速公路”,必须无状态、可无限水平扩展。我们用Go+gRPC实现,核心逻辑只有两个函数:
// router/server.go type RouterServer struct { pb.UnimplementedRouterServer // 无任何成员变量!纯函数式 } // 1. 消息路由入口:根据to列表,查路由表,分发到对应store func (s *RouterServer) RouteMessage(ctx context.Context, req *pb.RouteRequest) (*pb.RouteResponse, error) { // 从req.To列表中,对每个user_id查分片ID(查Redis路由表) shards := make(map[int][]string) // 分片ID → user_id列表 for _, uid := range req.To { shardID := getShardID(uid) // HGET user_shard_map uid shards[shardID] = append(shards[shardID], uid) } // 2. 并发调用各store服务(每个分片一个goroutine) var wg sync.WaitGroup results := make(chan error, len(shards)) for shardID, uids := range shards { wg.Add(1) go func(sid int, users []string) { defer wg.Done() // 调用store服务的WriteMessages RPC _, err := storeClient.WriteMessages(context.Background(), &pb.WriteRequest{ ShardID: sid, Messages: []*pb.Message{req.Msg}, ToUsers: users, }) results <- err }(shardID, uids) } // 3. 等待所有store返回,任一失败即返回错误 go func() { wg.Wait() close(results) }() for err := range results { if err != nil { return nil, err } } return &pb.RouteResponse{Success: true}, nil } // 4. 在线状态查询:查Redis,不走DB func (s *RouterServer) GetOnlineStatus(ctx context.Context, req *pb.StatusRequest) (*pb.StatusResponse, error) { // HGETALL online_status:uid-123,返回map[device_id]last_active_time status, err := redisClient.HGetAll(ctx, "online_status:"+req.UserID).Result() if err != nil { return nil, err } return &pb.StatusResponse{Status: status}, nil }Router的“无状态”体现在:
- 不存任何连接、不存任何消息、不存任何用户状态;
- 所有数据都来自外部(Redis路由表、store服务、etcd服务发现);
- 扩容只需起新实例,自动注册到etcd,流量自动分摊。
我们压测过:单Router实例(4C8G)可处理2.4万QPS的消息路由,瓶颈在Redis网络IO,而非CPU。当QPS超3万时,加一台Router,性能线性提升。
4.4 存储层实现:MySQL分片写入与Redis同步的原子性保障
消息既要存MySQL(持久化),又要存Redis(实时推送),如何保证两者一致?我们采用本地消息表+定时扫描方案,而非分布式事务(太重):
写入流程:
store服务收到消息,开启MySQL事务;- 插入消息到
messages_shard_x表; - 同一事务内,插入一条记录到
local_message_queue表(字段:shard_id,msg_id,status='pending'); - 提交事务。
同步流程:
- 独立的
sync_worker服务,每100ms扫描local_message_queue,取status='pending'的记录; - 将消息写入Redis(
LPUSH msg_queue:shard_x); - 若成功,更新
local_message_queue.status='done'; - 若失败,重试3次后置为
'failed',告警人工介入。
- 独立的
这个方案的优势:
- MySQL事务保证消息必落库;
- 异步同步避免阻塞主流程,写入TPS达8000+/秒;
local_message_queue表可建shard_id索引,扫描高效。
实操心得:
sync_worker必须用SELECT ... FOR UPDATE SKIP LOCKED扫描队列,否则多实例并发时会重复处理同一条消息。我们