多存储容灾
store/failover 的写入、读取与健康探测原语,完整的主备示例,以及如何在原语之上自己做副本补齐。
store/failover 在一组驱动之上提供四个无状态函数。驱动切片的顺序就是优先级:哪些驱动参与、谁健康、
对象在哪几个驱动上有副本,都由调用方决定后按优先级排好传进来,包本身不保存任何状态。
func Write(ctx context.Context, ds []driver.Driver, key string, r io.ReaderAt, size int64,
opts WriteOptions) ([]Attempt, error)
func Open(ctx context.Context, ds []driver.Driver, key string) (io.ReadCloser, []Attempt, error)
func Probe(ctx context.Context, d driver.Driver) error
func DeepProbe(ctx context.Context, d driver.Driver) errorWrite 与 Open 都返回 []Attempt,按尝试顺序记录实际访问过的每个驱动:
属性
类型
Write:按序写够 N 份
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 2, AttemptTimeout: time.Minute})属性
类型
- 按切片顺序逐个写,不并发。某个驱动写失败就顺延下一个;成功份数达到
Need立即返回,排在后面的驱动不会被访问。 - 每次尝试都从数据开头重新读。数据源是
io.ReaderAt加size,每次尝试单独读取[0, size),上一个驱动写到一半失败不影响下一个。*bytes.Reader、*os.File都满足io.ReaderAt。 ctx取消后立即停止:每次尝试前检查ctx,已取消就不再尝试后续驱动,直接返回ctx.Err(),而不是ErrInsufficient—— 取消造成的失败不能算作驱动的结论。- 返回值里
Err == nil的尝试,就是持有副本的驱动。成功时attempts里也可能包含失败的尝试。 - 失败的写入不会被清理:失败的驱动上可能残留半截对象,需要时自己删除,或交给对账处理。
Need大于驱动数时不会提前拒绝,而是试完全部驱动后返回ErrInsufficient。
| 错误 | 含义 |
|---|---|
ErrInvalidNeed | opts.Need 小于 1;最先检查 |
ErrNoDrivers | 驱动切片为空 |
ErrInsufficient | 全部驱动都试过,成功份数仍不足;同一个错误还包裹了每次失败的原因 |
ctx 的错误 | 尝试中途 ctx 被取消或到期 |
Open:按序读第一个可读的
rc, attempts, err := failover.Open(ctx, ds, key)
if err != nil {
return err
}
defer func() { _ = rc.Close() }()- 按切片顺序打开对象,第一个打开成功的立即返回,后续驱动不再访问。读取流由调用方
Close。 - 成功时
attempts的最后一项就是提供读取流的驱动,前面的都是失败的尝试,可用于日志或指标。 - 切换只发生在打开阶段。读取流打开之后中途出错,不会自动换到下一个驱动。
- 没有单次超时,只受
ctx约束;每次尝试前检查ctx,已取消就直接返回ctx.Err()。 对 rclone 驱动,ctx通常同时约束返回的读取流(HTTP 类后端的响应体随ctx取消而中断),读完之前不要取消它。
全部驱动都失败时,错误分两种:
| 情况 | 返回的错误 | 含义 |
|---|---|---|
| 每个驱动都报告对象不存在 | 包裹 driver.ErrNotFound | 对象确实缺失 |
| 至少一个驱动的失败不是「不存在」 | 包裹 ErrUnavailable;各驱动的失败原因只写进错误文本,不参与包裹 | 有驱动故障,对象可能还在 |
另外,驱动切片为空时返回 ErrNoDrivers。
两类错误互斥:errors.Is(err, driver.ErrNotFound) 为 true 只说明每个驱动都确认对象不存在,有驱动故障时它一定为 false。
ErrUnavailable 的错误不包裹各驱动的原始错误,要逐个判断某个驱动为什么失败,看返回的尝试记录:
rc, attempts, err := failover.Open(ctx, ds, key)
switch {
case errors.Is(err, driver.ErrNotFound):
// 所有驱动都确认对象不存在:例如 HTTP 404
case errors.Is(err, failover.ErrUnavailable):
// 有驱动故障,对象可能还在:按「暂时不可用」处理,例如 HTTP 503
for _, a := range attempts {
log.Printf("驱动 %d: %v", a.Index, a.Err)
}
case err != nil:
// ErrNoDrivers,或 ctx 被取消
default:
defer func() { _ = rc.Close() }()
// 读取 rc
}Probe 与 DeepProbe:健康探测
Probe(轻探测) | DeepProbe(深探测) | |
|---|---|---|
| 做什么 | 对约定键 ProbeKey 执行一次 Stat | 写入一个随机键的小对象 → 读回逐字节核对 → 删除 |
| 判为健康 | Stat 成功,或返回 driver.ErrNotFound | 写、读、删全部通过 |
| 成本 | 一次只读请求,不写不删 | 每次都有写入与删除请求 |
| 能发现 | 存储不可达、凭证无效 | 另外能发现「可读不可写」:只读凭证、桶满、配额耗尽 |
| 适合 | 周期执行 | 事件触发:新建、改配置、手动复查、恢复健康前确认 |
Probe 的超时完全由 ctx 决定,调用时应带超时。ProbeKey 的值是 .imgyoung-health-probe,约定从不写入,不要用这个键存数据。
DeepProbe 的细节:
- 临时对象键为
.imgyoung-probe-加 16 位十六进制随机串,内容是固定的 19 字节;随机键让并发的深探测互不干扰。 - 写入与读回受
ctx约束;删除使用脱离ctx取消的独立 10 秒超时,所以读回失败或ctx到期后仍会尽力删除。 - 写入、读回、内容核对任一环节失败都返回对应错误;读回已经失败时,删除的结果不再影响返回值;
读回成功而删除失败(且不是
driver.ErrNotFound)时返回删除错误。 - 删除失败会留下临时对象。
failover.IsDeepProbeKey(key)判断一个键是不是深探测临时对象,清理逻辑可据此认领回收。
只读探测成功不代表能写。一种稳妥的组合是应用采用的「只降不升」:周期轻探测只能把健康的存储判为不健康; 不健康的存储即使轻探测通过,也要再经深探测确认写读删全通,才恢复为健康。这样只读凭证这类「探测健康却写不进」的情况不会被下一轮轻探测误判为恢复。
完整示例
下面是仓库里的 store/failover/example_test.go:两个本地目录充当主、备存储。主存储的根路径落在一个普通文件之下,
任何写入都会失败,以此模拟主存储故障;写入顺延到备存储,读取同样切到备存储。它随 go test ./store/failover 执行,
输出与末尾的 Output 注释一致:
package failover_test
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/x-haose/imgyoung/store/driver"
"github.com/x-haose/imgyoung/store/failover"
"github.com/x-haose/imgyoung/store/rclone"
)
// Example 两个本地目录充当主、备存储,主存储故障时写入顺延到备存储,读取同样切到备存储。
func Example() {
ctx := context.Background()
dir, err := os.MkdirTemp("", "failover-example")
if err != nil {
fmt.Println("mkdir:", err)
return
}
defer func() { _ = os.RemoveAll(dir) }()
// 主存储的根落在一个普通文件之下,任何写入都会失败,以此模拟主存储故障。
blocker := filepath.Join(dir, "blocker")
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
fmt.Println("write blocker:", err)
return
}
primary, err := rclone.Open(ctx, rclone.Options{Backend: "local", Path: filepath.Join(blocker, "data")})
if err != nil {
fmt.Println("open primary:", err)
return
}
defer func() { _ = primary.Close(ctx) }()
backup, err := rclone.Open(ctx, rclone.Options{Backend: "local", Path: filepath.Join(dir, "backup")})
if err != nil {
fmt.Println("open backup:", err)
return
}
defer func() { _ = backup.Close(ctx) }()
ds := []driver.Driver{primary, backup} // 顺序即优先级
data := []byte("hello")
written, err := failover.Write(ctx, ds, "a.txt", bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 1})
if err != nil {
fmt.Println("write:", err)
return
}
for _, a := range written {
fmt.Println("write to", a.Index, "ok:", a.Err == nil)
}
rc, read, err := failover.Open(ctx, ds, "a.txt")
if err != nil {
fmt.Println("read:", err)
return
}
got, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
fmt.Println("read all:", err)
return
}
fmt.Println(string(got), "from", read[len(read)-1].Index)
// Output:
// write to 0 ok: false
// write to 1 ok: true
// hello from 1
}
自己做补齐
常见的用法是「主存储写成功就返回,其余存储在后台补上」:Write 用 Need: 1,把没写到的驱动记下来,稍后再复制。
// writePrimary 写够 1 份就返回,同时给出持有副本与待补齐的驱动下标。
func writePrimary(ctx context.Context, ds []driver.Driver, key string, data []byte) (holders, pending []int, err error) {
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 1, AttemptTimeout: time.Minute})
if err != nil {
return nil, nil, err
}
done := map[int]bool{}
for _, a := range attempts {
if a.Err == nil {
done[a.Index] = true
holders = append(holders, a.Index)
}
}
for i := range ds {
if !done[i] {
pending = append(pending, i)
}
}
return holders, pending, nil
}补齐一份副本就是「从持有副本的驱动里读出来,写进目标驱动」,读取这一步可以直接用 Open 的容灾:
// Replicate 把 key 从 holders(持有副本的驱动,按优先级排好)复制到 target。
// size 是对象字节数,通常取自你自己记录的元数据;没有记录时可以先对源驱动 Stat。
func Replicate(ctx context.Context, holders []driver.Driver, target driver.Driver, key string, size int64) error {
rc, attempts, err := failover.Open(ctx, holders, key)
if err != nil {
return fmt.Errorf("没有可读的源副本: %w", err)
}
for _, a := range attempts[:len(attempts)-1] {
log.Printf("源驱动 %d 读取失败,已切换: %v", a.Index, a.Err)
}
err = target.Put(ctx, key, rc, size)
if cerr := rc.Close(); err == nil {
err = cerr
}
return err
}这两段只是执行动作。要成为可靠的补齐机制,还需要你自己补上:
- 持久化副本状态:
holders与pending要落进你的存储,进程重启后能从中恢复未完成的补齐。 - 重试与退避:
Replicate失败时稍后重试,多次失败后标记为失败并告警,存储恢复后再重新尝试。 - 对账:定期对每个存储
List,与记录双向比对:存储里有、记录里没有的是孤儿对象(写入成功但记录落库失败时产生), 过了宽限期再删除;记录说有、存储里没有的副本要重新补齐。只回收能确认是自己写出的对象(例如按你自己的键名格式判断), 别的程序写进同一存储的文件不要动;深探测残留用IsDeepProbeKey认领,过宽限期即可删除。 - 并发控制:补齐任务与业务读写共用存储的带宽与请求配额,需要限制同时运行的数量。
应用用业务表 blob_channels 记录副本状态,用 River 任务队列执行补齐,做法见 架构。
应用如何使用这些原语
应用的渠道就是一组 rclone 驱动,上传、访问与健康探测都直接调用本包:
| 场景 | 调用 | 参数与处理 |
|---|---|---|
| 上传写入 | Write | 驱动为健康渠道,按优先级排序;Need 取运营配置 min_success(小于 1 时按 1);AttemptTimeout 为 10 分钟。成功的渠道记为副本就绪,其余渠道记为待补齐并入队补齐任务;份数不足返回 503 |
| 访问读取 | Open | 驱动为健康、不在清理中、且该图副本已就绪的渠道,按优先级排序;失败的尝试逐条记日志;全部失败返回 503 |
| 周期健康探测 | Probe / DeepProbe | 每隔 health_check_seconds 秒先重试装载此前装载失败的渠道,再对各渠道并行轻探测,单个渠道 10 秒超时;不健康的渠道轻探测通过后,再以 20 秒超时深探测确认才恢复 |
| 事件深探测 | DeepProbe | 创建渠道后、编辑渠道后、手动重检、测试连接,20 秒超时 |
补齐任务从候选源渠道逐个尝试读出、写入目标渠道,与上面的 Replicate 思路相同。每日的对账会跳过 ProbeKey 与系统目录 _imgyoung/,
只回收键名是应用对象键格式的孤儿(口径见 架构);深探测残留由 IsDeepProbeKey 认领,过 48 小时宽限期即删,
不看写入时刻。运营配置项见 配置。