Skip to content
Go back

不停机数据迁移——PostgreSQL到Redis ZSet的双写灰度三阶段

不停机数据迁移:PostgreSQL → Redis ZSet 的双写灰度三阶段

一句话结论(30s)

我把抢票活动的排行榜从 PostgreSQL 迁移到 Redis ZSet,用「双写 → 灰度切读 → 全量切换」三阶段实现不停机迁移、用户零感知,P99 延迟从 120ms 降到 8ms(↓93%)——因为 Redis ZSet 的跳表天然有序存储,查询就是范围遍历,省掉了每次查询的全表排序。

背景诉求

目标边界

核心难点

  1. 一致性:双写期间两套存储如何保持一致——新写入同时写 PostgreSQL 和 Redis,切读前必须先确认存量 + 增量数据都对得上。
  2. 双写顺序与失败处理:Redis 是新增依赖,写失败不能拖垮主流程,旧库 PostgreSQL 必须是完整的数据保障。
  3. 容灾 / 可回滚:活动进行中随时可能出问题,每一步都要可逆,能一路切回旧逻辑。

关键取舍

  1. 为什么选 Redis ZSet,而不是继续优化 PostgreSQL(加索引 / 物化视图):排行榜是天然有序的 Top-N 场景,ZSet 用跳表做 O(log N + M) 范围查询、无需每次排序;PostgreSQL 即便加索引,高并发下的 Top-N 排序仍是瓶颈。
  2. 为什么不停机直接切:活动进行中直接切换,一旦数据不一致或性能异常就没有兜底、回滚代价高;先双写预热让新库数据实时追上,再小比例灰度验证。
  3. 双写顺序:以旧库 PostgreSQL 为主,Redis 写失败可忽略 / 重试、不影响线上——因为旧库始终是完整的数据保障。

个人行动

按三阶段落地「双写 + 灰度切读」:

  1. 阶段一 · 双写 + 读旧库(预热期):抢票成功同时写 PostgreSQL 和 Redis,读仍走旧库,让 Redis 数据实时追上、完成预热。
  2. 阶段二 · 双写 + 读新库(灰度切流):先批量导入历史存量,确认两库一致后灰度放量 10% → 30% → 50% → 100%,每阶段对比延迟与数据正确性,异常立即切回 0%。
  3. 阶段三 · 切流完成 + 停写旧库:读流量 100% 到 Redis,观察 24h 稳定后移除双写代码,历史数据留 PostgreSQL 归档。

灰度从 10% 起而不是 50%,因为 10% 已能暴露绝大多数延迟异常和数据不一致,影响面只有 1/10 用户;50% 出问题时已影响一半用户。

结果与复盘

三版本回答

30 秒版(一句话)

我把抢票排行榜从 PostgreSQL 迁到 Redis ZSet,用「双写 → 灰度切读 → 全量切换」三阶段不停机迁移,P99 从 120ms 降到 8ms,因为 ZSet 跳表天然有序,省掉了每次查询的全表排序。

2 分钟版(电梯陈述)

抢票排行榜第一版用 PostgreSQL ORDER BY time LIMIT 10 查,活动并发激增后每次全表排序,CPU 飙升、接口超时。方案是把排行榜迁到 Redis ZSet,难点是活动期间不停机、用户零感知。我分三阶段落地:先「双写 + 读旧库」预热,让 Redis 数据实时追上;再批量导入存量、灰度 10% → 100% 切读,异常随时切回 0%;最后 100% 读 Redis、观察 24h 后停写旧库。结果 P99 从 120ms 降到 8ms(↓93%)。核心是双写兜底、灰度放量、随时可回滚。

5-10 分钟版(深度展开)

【背景】抢票 H5 排行榜,PostgreSQL ORDER BY LIMIT 10 全表排序,并发激增后 CPU 飙升、接口超时。 【难点】三件事:双写一致性、双写顺序与失败处理、容灾可回滚。 【方案】三阶段 + 灰度:双写 + 读旧库预热 → 双写 + 读新库灰度切流(10% → 30% → 50% → 100%)→ 切流完成停写旧库,每一步可回滚。 【取舍】为什么是 Redis ZSet 而不是继续优化 PostgreSQL——跳表天然有序、O(log N + M) 范围查询,免去每次排序。 【异常】Redis 写失败不影响线上(旧库兜底);灰度发现异常立即切回 0% 全读旧库。 【稳定】双写兜底 + 小比例灰度验证 + 24h 观察,最终 P99 从 120ms 降到 8ms(↓93%)。 其中「为什么 10% 起而不是 50%」和「跳表 vs 全表排序」最有意思,要不要展开?

底层深入(技术细节)

项目背景

国庆抢票 H5 活动,排行榜实时展示前 10 名抢票最快用户的响应时间。第一版用 PostgreSQL ORDER BY time ASC LIMIT 10 直接查——活动传播后并发激增,每次查排行榜都是全表排序,数据库 CPU 飙升,接口超时。

优化方向:排行榜迁移到 Redis ZSet(内存跳表 + 哈希表),读写全走内存。

核心挑战:如何在活动期间不停机完成数据迁移,对用户零感知。

阶段一:双写 + 读旧库(预热期)

写入路径:
  抢票成功 → INSERT INTO rank (PostgreSQL)     ← 旧逻辑不变
           → ZADD rank:daily <time> <uid> (Redis) ← 新写入并行

读取路径:
  查排行榜 → SELECT ... ORDER BY time LIMIT 10 (PostgreSQL) ← 仍走旧库

新写入同时写两套存储,但读流量仍在旧库。这一步让 Redis 数据实时追上,做数据预热。即使 Redis 写入失败也不影响线上——旧库 PostgreSQL 是完整的数据保障。

想一想:为什么双写期间 Redis 写失败可以忽略、旧库却必须完整? 因为 Redis 是新引入的依赖,它的失败不该拖垮主流程;而 PostgreSQL 是活动进行中的数据真相,是最后的兜底。只要旧库完整,任何时候都能从旧库重放、重导 Redis,把新库追平——所以双写的正确姿势是”以旧库为主,新库失败可重试、可忽略”。

阶段二:双写 + 读新库(灰度切流)

先批量将历史存量数据从 PostgreSQL 导入 Redis(一次性 ZADD),确认两库数据一致。然后灰度放量

10% → 读 Redis → 对比延迟和数据正确性 → 正确 → 30% → 50% → 100%

每个灰度阶段的回滚路径:发现问题立即切回 0%(全部读 PostgreSQL),修复后重新放量。

为什么 10% 开始而不是 50%?

10% 的流量已经能暴露绝大多数的延迟异常和数据不一致。如果 10% 就出了问题,影响面只有 1/10 的用户。50% 发现问题时已经影响了一半用户——回滚压力和用户投诉都大得多。

阶段三:切流完成 + 停写旧库

读流量 100% 到 Redis,观察稳定运行一段时间(如 24 小时),确认无误后:

  1. 移除 PostgreSQL 双写代码
  2. 历史排行榜数据保留在 PostgreSQL 做归档备份
  3. 部署仅写 Redis 的新版本

回滚保障:如果 Redis 出现问题,重新开启双写 + 读 PostgreSQL,一路切回旧逻辑。PostgreSQL 作为兜底始终可用。

想一想:为什么每一步都要”可回滚”? 因为迁移发生在活动进行中,任何一步出错都直接影响线上用户。可回滚意味着”切不过去随时能切回来”——灰度发现异常就 0% 全读旧库,Redis 挂了就重开双写 + 读旧库。迁移的关键从来不是”新库多快”,而是”切不过去怎么办”。

为什么 Redis ZSet 比 PostgreSQL 快两个数量级

PostgreSQL 路径SELECT ... ORDER BY time LIMIT 10 → 全表扫描(或索引扫描)→ 排序(O(N log N))→ 取前 10。每次查询都要重新排序。

Redis ZSet 路径:跳表(Skip List)+ 哈希表双结构:

排行榜天然有序存储,查询就是跳表的范围遍历——不需要排序。P99 从 120ms 降至 8ms(↓93%)。

想一想:为什么 ZSet 能比 PostgreSQL 快两个数量级? 因为 PostgreSQL 的 ORDER BY time LIMIT 10 每次查询都要全表扫描 + 排序,O(N log N);而 ZSet 用跳表把元素按 score(响应时间)天然有序存好,查前 10 只是 O(log N + 10) 的范围遍历,根本不用排序。一个是”每次现排”,一个是”早就排好了直接取”,这就是 120ms 和 8ms 的差距来源。

总结

不停机迁移的三个核心原则:双写兜底(旧库是安全保障)、灰度放量(小比例验证扩容)、随时可回滚(每一步都是可逆的)。

章末提问

Q1:双写期间怎么保证两套存储的数据一致性? 结论先行:靠”切读前先对账”——双写让增量实时追上,切读前再批量导入存量,确认存量 + 增量都对得上才切。 因为双写阶段读仍走旧库,Redis 只是被动地跟着旧库实时追增量;切读前把历史存量一次性导入,再对比两库数据一致后,才放量切读。切读后若发现不一致,立即切回 0% 全读旧库,把一致性风险控制在灰度窗口内。

Q2:为什么灰度从 10% 开始,而不是 50%? 结论先行:因为 10% 已能暴露绝大多数延迟异常和数据不一致,且影响面只有 1/10 用户。 因为灰度放量的目的是”用最小代价验证新库”,10% 的流量足够暴露出大部分问题;50% 发现问题时已经影响一半用户,回滚压力大得多。所以灰度是”小步快跑、随时可退”,而不是”一步到位赌一把”。

Q3:如果 Redis 在迁移过程中挂了,怎么回滚? 结论先行:重新开启双写 + 读 PostgreSQL,一路切回旧逻辑,旧库始终是完整兜底。 因为三阶段设计里 PostgreSQL 一直保留历史数据和写入口,Redis 只是新增依赖;一旦 Redis 出问题,读流量切回旧库、双写代码恢复,就能回到迁移前的稳定状态。这正是不停机迁移”随时可回滚”原则的体现。


Share this post on:

Previous Post
分布式事务——从2PC到Seata AT的选型全景
Next Post
Netty的Reactor模式——从单线程到主从多线程