# 多存储容灾 (/zh/docs/library/failover)

store/failover 的写入、读取与健康探测原语，完整的主备示例，以及如何在原语之上自己做副本补齐。

`store/failover` 在一组驱动之上提供四个无状态函数。**驱动切片的顺序就是优先级**：哪些驱动参与、谁健康、
对象在哪几个驱动上有副本，都由调用方决定后按优先级排好传进来，包本身不保存任何状态。

```go
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`，按尝试顺序记录实际访问过的每个驱动：

<TypeTable
  type={{
    Index: { type: 'int', description: '驱动在传入切片中的下标' },
    Err: { type: 'error', description: '这次尝试的错误；nil 表示成功' },
  }}
/>

## Write：按序写够 N 份 [#write按序写够-n-份]

```go
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
	failover.WriteOptions{Need: 2, AttemptTimeout: time.Minute})
```

<TypeTable
  type={{
    Need: { type: 'int', required: true, description: '最少成功份数，须 >= 1；凑够即停，其余驱动不再写' },
    AttemptTimeout: { type: 'time.Duration', description: '单个驱动一次写入的超时；0 表示只受 ctx 约束', default: '0' },
  }}
/>

- **按切片顺序逐个写，不并发**。某个驱动写失败就顺延下一个；成功份数达到 `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：按序读第一个可读的 [#open按序读第一个可读的]

```go
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` 的错误不包裹各驱动的原始错误，要逐个判断某个驱动为什么失败，看返回的尝试记录：

```go
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健康探测]

| | `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` 注释一致：

```go title="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
}
```

## 自己做补齐 [#replication]

常见的用法是「主存储写成功就返回，其余存储在后台补上」：`Write` 用 `Need: 1`，把没写到的驱动记下来，稍后再复制。

```go
// 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` 的容灾：

```go
// 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 任务队列执行补齐，做法见 [架构](../architecture.mdx)。

## 应用如何使用这些原语 [#应用如何使用这些原语]

应用的渠道就是一组 rclone 驱动，上传、访问与健康探测都直接调用本包：

| 场景 | 调用 | 参数与处理 |
|---|---|---|
| 上传写入 | `Write` | 驱动为健康渠道，按优先级排序；`Need` 取运营配置 `min_success`（小于 1 时按 1）；`AttemptTimeout` 为 10 分钟。成功的渠道记为副本就绪，其余渠道记为待补齐并入队补齐任务；份数不足返回 503 |
| 访问读取 | `Open` | 驱动为健康、不在清理中、且该图副本已就绪的渠道，按优先级排序；失败的尝试逐条记日志；全部失败返回 503 |
| 周期健康探测 | `Probe` / `DeepProbe` | 每隔 `health_check_seconds` 秒先重试装载此前装载失败的渠道，再对各渠道并行轻探测，单个渠道 10 秒超时；不健康的渠道轻探测通过后，再以 20 秒超时深探测确认才恢复 |
| 事件深探测 | `DeepProbe` | 创建渠道后、编辑渠道后、手动重检、测试连接，20 秒超时 |

补齐任务从候选源渠道逐个尝试读出、写入目标渠道，与上面的 `Replicate` 思路相同。每日的对账会跳过 `ProbeKey` 与系统目录 `_imgyoung/`，
只回收键名是应用对象键格式的孤儿（口径见 [架构](../architecture.mdx)）；深探测残留由 `IsDeepProbeKey` 认领，过 48 小时宽限期即删，
不看写入时刻。运营配置项见 [配置](../configuration.mdx)。
