Go 库

多存储容灾

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) error

Write 与 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。
错误含义
ErrInvalidNeedopts.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 注释一致:

store/failover/example_test.go
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 小时宽限期即删, 不看写入时刻。运营配置项见 配置。

本页目录