GeoDNS 把用户切到了没有数据的区域
「刚好」商城有三个区域:cn、eu、us。入口用 GeoDNS 按用户位置返回最近区域的地址,每个区域前面有一个健康检查,探测 /healthz 是否返回 200。这套架构跑了很久,故障演练也做过几次,看起来没有问题。
某个周二,cn 区的数据库主节点因为磁盘故障进入只读,应用进程还在,/healthz 仍然返回 200,因为健康检查只验证进程存活,不验证数据库可写。GeoDNS 没有切换,cn 用户开始大量下单失败。运维手动把 cn 从 DNS 里摘掉,流量按预期落到 eu。五分钟后,客服收到另一批投诉:eu 的用户能打开首页,但登录全部失败,提示“会话不存在”。
排查发现,会话数据写在 cn 区的 Redis,跨区复制有延迟,而且登录服务在 eu 只读本地副本。更严重的是,故障前几分钟注册的新用户,资料还没复制到 eu,登录时查不到账号。健康检查看到的是“eu 的进程活着”,用户看到的是“我的账号消失了”。这两个“健康”不是一回事。
事后看,最贵的不是故障本身,而是团队对故障的想象太窄。预案里写了“机房不可用”,没写“机房可用但数据库只读”;写了“进程崩溃”,没写“进程正常但复制积压”。演练覆盖不到的状态,真实故障会替团队补上这一课。
运维后来复盘时承认,团队对“区域故障”的理解只停留在机器和进程。机房断电、网卡故障、进程崩溃,这些场景演练过;数据库只读、复制积压、依赖服务降级,这些场景没有演练。健康检查的指标决定了团队能看到什么,看不到的故障就等于不存在。
这次事故暴露了一个常见误区:把就近接入当成一个 DNS 问题。DNS 能解决“请求发到哪个入口”,解决不了“那个入口有没有能力处理这个用户的数据”。路由必须同时考虑区域健康和该区域对当前用户的数据可用性。
就近接入不等于就近有数据
多区域路由有三层:入口层选区域、应用层选实例、数据层选副本。GeoDNS 只做第一层,而且它的健康信号通常很粗糙。把三层混在一起讨论,就会得到“DNS 切了,问题就解决了”的错觉。
| 路由方式 | 感知进程健康 | 感知数据就绪 | 切换是否可复现 | 典型问题 |
|---|---|---|---|---|
| 纯 GeoDNS | 否 | 否 | 是 | 区域故障时仍然把用户送过去 |
| DNS + 健康检查 | 部分 | 否 | 是 | 进程活着但数据库不可写,检查通过 |
| 应用层固定顺序路由 | 是 | 否 | 是 | 切到健康区,但该区没有用户数据 |
| 固定顺序 + 数据就绪检查 | 是 | 是 | 是 | 需要维护复制延迟和会话归属 |
| 多主同时写入 | 是 | 部分 | 否 | 冲突解决复杂,跨区延迟仍然存在 |
本章的 Route 实现的是第三行:区域级、固定顺序、只读健康表。第四行的数据就绪检查由调用方在 Route 之后做,因为“这个用户的数据在哪个区”属于业务状态,不应该塞进一个通用的路由函数。
顺序还决定了容量规划。cn→eu→us 意味着 cn 故障时 eu 要承接 cn 的流量,us 是第二备选。如果 eu 的容量只按本地峰值设计,切过去就会过载,健康检查随即失败,流量再被推向 us,形成级联切换。故障预案里必须写清楚每个区域要预留多少溢出容量。
顺序不是随便排的。把 cn 放在第一备选,通常是因为 cn 的容量最大、数据最全;把 us 放最后,是因为跨洋延迟最高。顺序一旦确定,就要和容量、数据复制方向、合规边界一起 review,不能由某个服务的配置文件单独决定。
固定顺序很重要。如果每次故障都按 map 遍历顺序或随机挑选,同一批用户可能被切到不同区域,会话、购物车和幂等键都会分裂。固定顺序让切换结果可复现,排查时能回答“为什么这个用户去了 eu 而不是 us”。
还有一个容易忽略的约束:切区不是瞬时的。DNS 有 TTL,客户端有缓存,长连接不会因为 DNS 变化而立刻断开。应用层路由可以在每次请求时重新判断,但它只能影响新请求,已经建立的连接仍然留在旧区。设计故障预案时要把这部分“尾巴流量”算进去。
路由表里还有一个隐含假设:健康是区域级的。真实系统里,健康往往是分片的:cn 的订单库可写,但推荐服务超时;eu 的登录可用,但支付通道故障。区域级布尔值会把这些差异抹平,调用方拿到“健康”之后仍然可能失败。本章的 Route 只处理区域级决策,更细的降级由各服务自己的熔断和超时控制负责。
事故当天的监控也误导了团队。区域健康面板全绿,告警只有“cn 数据库只读”一条;登录失败率上升被归因于“用户重试”,没有触发区域级告警。监控指标按服务聚合,没有按“区域 × 用户归属”聚合,切区造成的局部失败被平均掉了。
失败场景:备用区健康,但用户数据不在
回到开头的事故。eu 的健康检查通过,是因为它只验证了进程和本地 Redis;登录服务读的是本地会话副本,而 cn 区刚写入的会话还没复制过来。复制延迟平时是 200 毫秒,故障切换时因为积压涨到了几十秒。用户在这几十秒内登录,就会看到“会话不存在”。
第二个问题是账号资料。新注册用户的写入落在 cn 区的主库,异步复制到 eu。切换发生时,一部分用户的资料还在路上。登录服务先查本地副本,查不到就返回“账号不存在”,没有回源到 cn 区,也没有重试。用户看到的是账号被删除,实际只是复制没跟上。
第三个问题是幂等。用户在 cn 区提交订单失败后重试,请求被切到 eu,eu 没有这笔订单的幂等记录,于是又创建了一笔。两边最终都成功,用户被扣了两次款。跨区切换时,幂等键的存储位置和复制状态必须和业务数据一起考虑,不能假设“同一个 key 在哪个区都能查到”。
修复分四步。第一,健康检查从“进程活着”升级为“能处理当前流量”,至少包括数据库可写和复制延迟在阈值内。第二,路由之后增加数据就绪检查:会话不存在时回源到归属区,而不是直接报错。第三,切换期间对写请求做短暂限流,给复制留出追赶时间。第四,幂等记录按用户归属区存储,跨区请求先解析归属再处理。
演练也要改。原来的演练只验证“把 cn 摘掉,首页还能打开”,现在要验证“cn 摘掉后,最近十分钟注册的用户能不能登录、故障前提交的订单会不会重复”。演练脚本要覆盖写请求、会话、幂等和复制延迟,而不是只 curl 首页。
这四步里,只有第一步是基础设施问题,后三步都是应用层设计。指望更换 DNS 或负载均衡器解决全部问题,是把应用状态当成了网络状态。
把路由写成纯函数还有一个运维上的好处:可以在发布前离线回放。把线上采集的健康快照和用户区域输入喂给 Route,就能算出每个用户会被切到哪个区,提前发现容量和顺序问题。带 I/O 的路由函数做不到这一点。
用 Route 做确定性切区
Route 的职责很小:给定用户区域和一张健康表,返回承接请求的区域。它不查数据库、不读复制延迟、不改健康表。职责小,才能在每个请求上安全调用,也才能用表驱动测试覆盖所有顺序。
// file: internal/i18n/routing.go
// tier: L1
// verify: go test ./internal/i18n/
package i18n
import (
"errors"
"fmt"
)
var (
// ErrUnknownRegion 表示用户所在区域不在支持列表内。
ErrUnknownRegion = errors.New("i18n: unknown region")
// ErrNoHealthyRegion 表示没有任何可用区域可以承接请求。
ErrNoHealthyRegion = errors.New("i18n: no healthy region")
)
// fallbackOrder 是故障时的固定切换顺序。固定顺序让路由结果可复现,
// 不会因为 map 遍历顺序不同而在两台机器上给出不同答案。
var fallbackOrder = []Region{RegionCN, RegionEU, RegionUS, RegionCN}
// Route 选择承接请求的区域:优先用户本区,本区不健康时按
// cn→eu→us→cn 的顺序找下一个健康区。healthy 只读,不会被修改。
func Route(userRegion Region, healthy map[Region]bool) (Region, error) {
if !knownRegion(userRegion) {
return "", fmt.Errorf("%w: %s", ErrUnknownRegion, userRegion)
}
if healthy[userRegion] {
return userRegion, nil
}
for _, region := range fallbackOrder {
if region == userRegion {
continue
}
if healthy[region] {
return region, nil
}
}
return "", fmt.Errorf("%w: user=%s", ErrNoHealthyRegion, userRegion)
}
func knownRegion(region Region) bool {
switch region {
case RegionCN, RegionEU, RegionUS:
return true
default:
return false
}
}
实现里有几个关键点:
- 先校验
userRegion是否已知。未知区域直接报ErrUnknownRegion,不会落到某个默认区,避免拼写错误把流量送到错误地方。 - 本区健康时直接返回本区。这是最常见路径,也是一次 map 查找,成本可以忽略。
- 本区不健康时按
fallbackOrder的固定顺序找下一个健康区。顺序是 cn→eu→us→cn,跳过用户本区,结果与 map 遍历顺序无关。 fallbackOrder末尾重复 cn 是为了表达“环形顺序”。对 cn 用户来说 cn 会被跳过,重复项不会改变结果;对 us 用户来说,它保证 cn 是最后一个候选。- 全部不健康时返回
ErrNoHealthyRegion,调用方可以据此返回 503 或进入降级页面,而不是继续重试。 - 函数只读
healthy,不写入、不删除。调用方可以安全地并发调用,也可以复用同一张健康表。
调用方的典型流程是:先 Route 拿到目标区,再查会话归属;如果会话不在目标区,回源到归属区读取,或在目标区触发一次受控的会话重建。重建要有速率限制,否则切区瞬间所有用户同时重建会话,会把目标区打垮。
Route 不判断“数据是否就绪”。如果 eu 健康但用户数据还在 cn,Route 仍然会返回 eu;调用方需要在拿到区域后检查会话归属和复制延迟,必要时回源。这个分工是有意的:路由函数保持纯粹,业务状态留在业务层。
测试用例的名字直接写业务含义,而不是 Test1、Test2。故障切换是低频路径,半年后回来看代码的人需要从测试名快速恢复上下文。名字里的 “cn falls to eu first” 比任何注释都直接。
只读测试容易被忽略。路由函数看起来不会写 map,但实现里如果为了“缓存健康结果”而加一行赋值,就会在并发调用时引入数据竞争。测试在单协程下未必能发现,go test -race 才能稳定暴露。本章的实现没有写操作,测试用内容比较把这条约束钉住。
测试:本区优先、固定顺序与只读
测试覆盖四类行为:本区健康时不被抢走、本区故障时按固定顺序回退、全部故障时返回明确错误、未知区域和入参 map 不被修改。
// file: internal/i18n/routing_test.go
// tier: L1
// verify: go test ./internal/i18n/
package i18n
import (
"errors"
"testing"
)
func TestRoutePrefersHomeRegion(t *testing.T) {
healthy := map[Region]bool{
RegionCN: true,
RegionEU: true,
RegionUS: true,
}
for _, region := range []Region{RegionCN, RegionEU, RegionUS} {
got, err := Route(region, healthy)
if err != nil {
t.Fatalf("Route(%s) error = %v", region, err)
}
if got != region {
t.Fatalf("Route(%s) = %s, want home region", region, got)
}
}
}
func TestRouteFallbackOrder(t *testing.T) {
cases := []struct {
name string
user Region
healthy map[Region]bool
want Region
}{
{
name: "cn falls to us when eu also down",
user: RegionCN,
healthy: map[Region]bool{RegionCN: false, RegionEU: false, RegionUS: true},
want: RegionUS,
},
{
name: "cn falls to eu first",
user: RegionCN,
healthy: map[Region]bool{RegionEU: true, RegionUS: true},
want: RegionEU,
},
{
name: "eu falls to cn first",
user: RegionEU,
healthy: map[Region]bool{RegionCN: true, RegionUS: true},
want: RegionCN,
},
{
name: "us falls to eu before cn when eu is first healthy",
user: RegionUS,
healthy: map[Region]bool{RegionCN: false, RegionEU: true},
want: RegionEU,
},
{
name: "us falls to cn when eu is down",
user: RegionUS,
healthy: map[Region]bool{RegionCN: true, RegionEU: false},
want: RegionCN,
},
}
for _, c := range cases {
got, err := Route(c.user, c.healthy)
if err != nil {
t.Fatalf("%s: Route error = %v", c.name, err)
}
if got != c.want {
t.Fatalf("%s: Route(%s) = %s, want %s", c.name, c.user, got, c.want)
}
}
}
func TestRouteNoHealthyRegion(t *testing.T) {
if _, err := Route(RegionCN, map[Region]bool{RegionEU: false}); !errors.Is(err, ErrNoHealthyRegion) {
t.Fatalf("all down error = %v, want ErrNoHealthyRegion", err)
}
if _, err := Route(RegionEU, nil); !errors.Is(err, ErrNoHealthyRegion) {
t.Fatalf("nil map error = %v, want ErrNoHealthyRegion", err)
}
}
func TestRouteRejectsUnknownUserRegion(t *testing.T) {
if _, err := Route(Region("ap"), map[Region]bool{RegionCN: true}); !errors.Is(err, ErrUnknownRegion) {
t.Fatalf("unknown region error = %v, want ErrUnknownRegion", err)
}
}
func TestRouteDoesNotMutateHealthMap(t *testing.T) {
healthy := map[Region]bool{
RegionCN: false,
RegionEU: true,
RegionUS: true,
}
before := len(healthy)
if _, err := Route(RegionCN, healthy); err != nil {
t.Fatalf("Route error = %v", err)
}
if len(healthy) != before {
t.Fatalf("map size changed from %d to %d", before, len(healthy))
}
if healthy[RegionCN] || !healthy[RegionEU] || !healthy[RegionUS] {
t.Fatalf("map contents changed: %+v", healthy)
}
}
顺序测试里的每个场景都只让一个或两个区健康,避免“碰巧也能通过”的用例。例如 cn 故障时同时让 eu 和 us 健康,才能证明实现选的是 eu 而不是 us;如果只让 us 健康,任何实现都会返回 us,测试就失去意义。
TestRoutePrefersHomeRegion 让三个区全部健康,断言每个用户都留在本区。TestRouteFallbackOrder 用五个场景固定顺序:cn 故障时先试 eu 再试 us,eu 故障时先试 cn,us 故障时按 cn→eu 的顺序选择。TestRouteNoHealthyRegion 覆盖“只有本区以外全down”和 nil map 两种情况。TestRouteRejectsUnknownUserRegion 确认未知区域不会被当成默认区。TestRouteDoesNotMutateHealthMap 在调用前后比较 map 的大小和内容,防止实现偷偷写入缓存。
这些测试不依赖网络、DNS 或真实健康检查,因此可以在 CI 里稳定重复。它们证明的是路由决策本身,不证明数据已经就绪。生产环境还需要集成测试:切区后登录是否成功、幂等是否一致、复制延迟是否在阈值内。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/i18n/ -run 'TestRoute' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRoutePrefersHomeRegion
--- PASS: TestRoutePrefersHomeRegion (0.00s)
=== RUN TestRouteFallbackOrder
--- PASS: TestRouteFallbackOrder (0.00s)
=== RUN TestRouteNoHealthyRegion
--- PASS: TestRouteNoHealthyRegion (0.00s)
=== RUN TestRouteRejectsUnknownUserRegion
--- PASS: TestRouteRejectsUnknownUserRegion (0.00s)
=== RUN TestRouteDoesNotMutateHealthMap
--- PASS: TestRouteDoesNotMutateHealthMap (0.00s)
PASS
ok webapp/internal/i18n 0.230s
这五个用例覆盖的是决策逻辑,不覆盖网络和存储。生产环境的验证还需要故障注入:主动让一个区不可写,观察流量是否按顺序切换、用户是否能在可接受的时间内恢复。单元测试保证顺序正确,故障注入保证顺序在真实系统里生效。
PASS 表示 5 个路由用例全部通过。它们证明固定顺序和只读约束被正确实现,不证明多区域部署已经具备数据一致性。
多区域部署的难点不在“把服务部署到三个地方”,而在“切过去之后用户还能不能正常使用”。入口、应用、数据三层要分别设计,健康信号要能反映真实处理能力,切区顺序要固定且可演练。把这些做完,就近接入才是一个完整方案,而不是一张 DNS 记录。
小结
就近接入是入口层的事,数据可用性是应用层的事。GeoDNS 能选最近区域,但不能保证那个区域有用户的会话、账号和幂等记录。路由要用固定顺序、明确报错、只读健康表,切区之后还要检查数据就绪并给复制留出追赶时间。下一章进入后台系统,讨论权限与审计如何避免“能登录就能看所有数据”。
阅读导航:上一节:11.2 数据驻留与合规 · 下一节:12.1 后台的权限与审计 。
- 只按地理距离选区域,不看数据归属。离得近但数据不在,体验比跨区访问更差。
常见错误与边界
- 把 GeoDNS 当成完整的高可用方案。它只决定入口,不知道数据库是否可写、用户数据是否在本地。
- 健康检查只探测进程存活。数据库只读、复制延迟飙升、依赖超时都可能让检查通过。
- 回退顺序随机或依赖 map 遍历。同一批用户会被切到不同区域,会话和幂等键随之分裂。
- 切区后不检查数据就绪。备用区健康但缺会话、缺账号,用户看到的是登录失败。
- 假设 DNS 切换是瞬时的。TTL、客户端缓存和长连接会让旧区继续收到流量。
- 幂等记录只存在本区。跨区重试会重复创建订单或重复扣款。
- 未知用户区域落到默认区。拼写错误或新区域上线时,流量会被静默送错地方。
- 让
Route承担数据查询。路由函数一旦有 I/O,就很难在每个请求上调用,测试也会变得不稳定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。