复制滞后与多端同步

前言 主从复制可以显著提升系统的读能力,但复制滞后会直接破坏多端同步体验。 本文聚焦三个最常见的一致性问题:读自己写、单调读、前缀一致读,以及在即时通讯场景中的对应治理手段。 读自己的写 读自己写失效:客户端提交了写入,但随后的读取请求被路由到了尚未完成同步的从节点,导致客户端读不到自己刚刚写入的数据。 用户读自己写数据,强制走主节点。如果大部分数据都修改,会给主库造成巨大压力 用户记录最近更新时间戳,可以是逻辑时间和系统时钟(时钟不可靠)。多设备不适用,并不知道记录的时间戳。 单调读 单调读失效:用户的多次读取请求打到了同步进度不一致的多个副本上,导致用户先看到了较新的数据,随后又看到了较旧的数据,出现了“时光倒流”。 始终从同一副本读取,例如基于用户id哈希。但是如果副本失效,必须重新路由到另一个副本 前缀一致读 前缀/因果一致性失效:具有因果关系(先后顺序)的写入,由于底层分布式组件的处理速率不一致,导致在第三方的视角中,事件发生的顺序被颠倒。 将具有因果顺序的都交由一个分区处理。效率低 即时通讯场景下的主从滞后问题 读自己写失效(Read-Your-Writes Anomaly) 多端同步丢失: 用户在手机端发了一条消息,成功写入服务端主库。用户立刻打开电脑端(PC 版)查看,PC 端的拉取请求恰好打到了一个由于网络抖动而延迟了 500 毫秒的 MySQL 从库。结果 PC 端界面上一片空白,用户以为消息没发出去。 解决方案:纯内存 Push 模型跑赢物理复制 放弃让其他在线端去“查”数据库的传统做法。 手机端消息先进入 Kafka,由消息处理服务按会话维度分配 Seq 并完成落库。 后端的 msg_transfer 服务消费 Kafka,定位到 PC 端的 WebSocket 长连接,直接将消息从内存推(Push)过去。 核心逻辑: 内存与网络的流转速度远快于磁盘 IO 和数据库 Binlog 复制。客户端直接利用 Push 过来的数据渲染上屏,从物理架构上彻底绕开了从库延迟的陷阱。 单调读失效(Monotonic Reads Anomaly) 漫游消息凭空消失: 用户断网重连,触发历史消息拉取(Pull)。第一次拉取,网关路由到了延迟极低的从库 A,用户看到了最新的 10 条消息。用户立刻下拉刷新,第二次请求被路由到了卡顿的从库 B,从库 B 还没这 10 条消息。于是,用户屏幕上刚刚还在的 10 条最新聊天记录突然集体消失。 解决方案:客户端主导的严格 Seq 游标 在 OpenIM 中,拉取漫游消息的“游标控制权”在客户端手里,而不是服务端盲查。 客户端本地 SQLite 记录着自己当前看到的最后一条连续消息的 MaxLocalSeq(如 Seq=100)。 发起 Pull 请求时,客户端携带极其明确的条件:“只拉取 Seq > 100 的增量消息”。 核心逻辑: 服务端接收到游标后,会和 Redis 中维护的 ServerMaxSeq 对比。如果查到的从库最新数据只有 Seq=95,服务端立刻判定该副本滞后,可以选择等待、报错或强制回源主库。这保证了客户端拉取的数据永远是向前递增的,彻底封杀时光倒流。 前缀/因果一致性失效(Causal Consistency Anomaly) 旁观者视角的逻辑错乱: 在百人群聊中,用户 A 问:“去不去吃饭?”,用户 B 看到后秒回:“去”。(A 绝对发生在 B 之前)。然而,A 和 B 的消息并发打入服务端,处理 B 的线程极快,处理 A 的线程卡顿,导致 B 的消息先同步到了部分从库。此时,旁观者 C 刷新群聊,竟然先看到了 B 说“去”,过了几秒才看到 A 问“去不去吃饭”。因果逻辑彻底崩塌。 解决方案:Kafka 分区串行化 + 会话内 Seq 发号 步骤 1(先入队): 消息以 ConversationID 为 Hash Key 投递到 Kafka,确保同一会话的消息进入同一个 Partition。 步骤 2(再发号): 消息处理服务按 Partition 顺序消费,并为该会话分配严格递增的 Seq,然后落库。 步骤 3(客户端连续性校验): 就算网络抖动导致 Seq=11 的消息先推给旁观者 C,客户端发现本地缺少 Seq=10 时,会先放入重排缓冲区,待 Pull 补齐后再按序展示。 核心逻辑: 先利用 Kafka 分区保证同会话处理顺序,再用会话内递增 Seq 做最终顺序锚点,最后由客户端状态机兜底连续性。 分布式架构设计:不与底层的物理不确定性(网络抖动、磁盘延迟)死磕,而是通过高层的应用逻辑(Seq、Push/Pull、分区 Hash)来建立确定的秩序 ...

March 22, 2026

LSM-Tree 原理与消息存储选型

1. 概述 LSM-Tree(Log-Structured Merge-Tree)是一种典型的写优化存储结构。它的核心思路是:不追求每次写入都直接落到最终有序位置,而是将写入拆成**“前台快速落地 + 后台异步整理”**两个阶段。 设计目标可以归纳为: 顺序化写入:前台写入以追加为主,避免随机写。 后台整理:异步 Compaction 控制查询路径复杂度。 可调节的权衡:在写吞吐、读延迟和空间占用之间提供调优空间。 2. 整体架构与核心组件 LSM-Tree 的运行依赖以下组件协同工作: 组件 位置 职责 WAL(Write-Ahead Log) 磁盘 写前日志,保证崩溃恢复 MemTable 内存 有序表,接收实时写入 Immutable MemTable 内存 写满后冻结,等待刷盘 SSTable 磁盘 有序、不可变的持久化文件 Compaction 后台任务 合并 SSTable,版本收敛与空间回收 数据在组件间的流转路径如下: flowchart LR W[Write Request] --> L[Append WAL] L --> M[Insert MemTable] M -->|Threshold reached| IM[Immutable MemTable] IM --> F[Flush to SSTable] F --> C[Compaction] C --> S[(Merged SSTables)] 3. 写路径 写入的关键原则是 “先确认可恢复,再确认可查询”。具体步骤: 追加 WAL——保证持久化,崩溃后可重放。 更新 MemTable——对外可查。 冻结 MemTable——达到阈值后转为 Immutable。 Flush 为 SSTable——后台将 Immutable MemTable 写入磁盘。 这条路径带来的关键特性: ...

March 22, 2026

分布式 IM 网关路由架构:从集中式 Redis 到连接即路由

分布式 IM 网关路由架构分析与设计 单机 IM 网关阶段,用户与 WebSocket 连接关系维护在本地内存即可,例如使用 sync.Map 或 map[int64]*websocket.Conn + RWMutex。该模式实现简单、链路短、时延低。 当在线规模持续增长后,单机的 TCP 连接数、CPU 核数与网络带宽会触达上限,系统必须演进为多实例网关集群。此时核心问题变为: 用户分散在不同网关实例后,A 给 B 发消息时,如何在毫秒级准确定位 B 所在节点? 本文围绕该问题展开,重点覆盖接入层方案、路由演进路径、集中式 Redis 架构在高规模下的瓶颈,以及“连接即路由”的去中心化方向。 一、接入层:客户端先连到谁 在内部路由之前,需要先确定客户端第一跳接入方式。主流做法通常分为两类。 1. 前置负载均衡接入(Nginx/LVS/SLB) 在网关集群前加反向代理,客户端连接统一入口,再由代理层分发到后端网关。 特点:接入结构直观,客户端侧配置简单。 问题:代理层同样承担长连接状态,在高并发七层代理场景下会带来明显内存与成本压力。 2. Dispatch 服务引流(服务发现 + 直连) 客户端先请求无状态的 HTTP Dispatch 接口。Dispatch 根据网关健康度、负载指标或一致性 Hash 返回目标地址,例如 192.168.1.100:8080,然后客户端直接连目标网关。 优势:去掉代理层代持长连接成本,网络拓扑更扁平。 代价:对调度策略、健康探测和故障转移能力要求更高。 二、路由策略:A 如何找到 B 接入路径确定后,核心问题回到路由:A 发给 B,A 所在网关如何将消息准确投递到 B 所在网关。 演进一:全局广播(Broadcast) A 所在网关把消息广播给所有网关实例。每个实例检查本地连接表,命中就投递,未命中就丢弃。 优点:实现成本低,网关可保持近似无状态。 缺点:单聊被放大为 $N$ 份网络与计算开销($N$ 为网关节点数),规模增大后资源浪费显著。 演进二:Redis 集中式路由(Centralized Routing) 为避免广播风暴,可将 Redis 作为全局路由目录:用户登录时写入“用户-网关”绑定,发消息前查询 Redis 后再定向投递。 ...

March 13, 2026

Id Seq Generation

分布式 ID / 序列号生成方案技术选型 1. 方案概述 1.1 UUID 128 位随机数(V4)或基于时间+MAC(V1)。无需中心节点,生成即唯一。 完全无序、不连续,无法用于排序或范围查询 36 字节字符串存储开销大,随机值导致 B+Tree 页分裂,写入性能差 1.2 数据库自增 ID MySQL AUTO_INCREMENT 主键,单表内严格递增且连续。 每次发号伴随磁盘写(INSERT/UPDATE),单机上限约 1000-3000 TPS 强依赖单点数据库,宕机即停;分库后各库独立递增,全局不连续 1.3 Snowflake(雪花算法) 64 位 = 1 bit 符号 + 41 bit 时间戳 + 10 bit 机器 ID + 12 bit 序列号。纯内存计算,单机 400 万+/秒。 趋势递增但不连续,两个 ID 的差值无业务含义 依赖机器时钟,时钟回拨可能导致 ID 重复 1.4 Leaf-Segment(美团 Leaf 号段模式) 从数据库批量预取号段(如 1000 个),应用内存中顺序分发。当前号段消耗到阈值时异步预加载下一号段,避免切换时阻塞。 号段内连续,但进程重启时未消费的号段被浪费,产生空洞 多实例部署时各进程持有不同号段,同一业务维度内 ID 交叉,无法保证连续 1.5 Leaf-Snowflake Snowflake 变体,用 ZooKeeper 管理 workerID 并解决时钟回拨。本质仍是 Snowflake,不连续的缺陷不变。 ...

March 5, 2026

Mysql Slow Query Filesort Optimization

IM 消息拉取接口的 SQL 优化记录 1. 背景与问题 在开发 IM 系统的“拉取历史消息”功能时,我发现当单张消息表(messages)的数据量达到百万级,且某个热点会话(conversation_id)拥有超过 10 万条消息时,接口响应出现明显卡顿。 环境信息: 数据库:MySQL 8.0+ (InnoDB) 数据量:单表 100 万行,大会话 10 万条信息 场景:用户查看最新的 20 条消息(Top-N 查询) 慢查询 SQL: SQLSELECT * FROM messages WHERE conversation_id = 'chat_hot' ORDER BY seq DESC LIMIT 20; 2. 排查过程 2.1 慢日志抓取 开启 MySQL 慢查询日志(log_output=FILE, long_query_time=0.1),捕获到该语句的执行情况: TEXT# Query_time: 0.148528 Lock_time: 0.000003 Rows_sent: 20 Rows_examined: 151676 异常点:为了返回 20 条数据,MySQL 实际扫描了 15 万行数据。扫描/返回比极低,说明索引效率存在严重问题。 2.2 执行计划分析 (EXPLAIN) 执行 EXPLAIN 查看当前索引使用情况: ...

December 28, 2025

Rockscache Consistency

深入解析 RocksCache:如何优雅地解决缓存与数据库一致性问题 本文深入剖析 RocksCache 的设计思想与核心实现,带你理解这个首创的缓存一致性解决方案。 前言 在分布式系统中,缓存是提升性能的利器,但也是一致性问题的重灾区。你是否曾经遇到过这样的困扰: 明明更新了数据库,为什么缓存里还是旧数据? 用了「先更新DB,再删缓存」策略,为什么还是会出现不一致? 如何在保证一致性的同时,还能保持高性能? 今天介绍的 RocksCache,是一个来自 DTM Labs 的开源项目,它通过一套精巧的设计,在不引入版本号的前提下,优雅地解决了缓存与数据库的一致性难题。 一、经典的缓存一致性问题 1.1 常见的缓存策略 最常用的缓存管理策略是 Cache-Aside(旁路缓存): TEXT读取:先查缓存 → 缓存命中则返回 → 未命中则查DB → 写入缓存 → 返回 更新:更新DB → 删除缓存 这个策略看似简单,却隐藏着一个致命的并发问题。 1.2 并发场景下的数据不一致 考虑以下时序: TEXT时间 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→ 线程A(读请求): 查DB(v1) ─────────────────────────────→ 写缓存(v1) (网络延迟) 线程B(写请求): 更新DB(v2) → 删除缓存 问题:线程A 查询到 v1 后,发生了网络延迟。此时线程B 完成了更新并删除缓存。但线程A 的写缓存操作在删除之后执行,导致缓存中存储了旧值 v1。 这就是著名的 “删除后写入” 问题,常规的「先更新DB再删缓存」策略无法解决。 1.3 传统解决方案的局限 方案 描述 缺点 延迟双删 删除缓存后,延迟一段时间再删一次 延迟时间难以确定,仍有不一致窗口 版本号 每条数据带版本号,写入时比较版本 侵入业务,改造成本高 分布式锁 读写都加锁 性能差,热点数据成为瓶颈 订阅 binlog 通过 Canal 等订阅 DB 变更 架构复杂,延迟较高 有没有一种方案,既能保证一致性,又不侵入业务,还能保证高性能? ...

December 23, 2025