diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 12ef3e0..1082e7f 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -182,6 +182,22 @@ - 备选方案:先关 listener、Drain、再 Shutdown。 - 影响:有 MQTT 长连接后需 N/P 联调停机路径。 +### P2 2026-09-30 + +1. **合并写入队列替换 T0.3 简单实现** + - 原条款:DEVELOPMENT 7.2;TASKS P2。 + - 实际做法:单写 goroutine;批次上限 256 或等待 2ms;每操作用 `SAVEPOINT`/`ROLLBACK TO`/`RELEASE`;Begin/Commit/SAVEPOINT 基础设施失败返回 `errors.Join(ErrBusy, err)` 并令 `IsReady()=false`;业务操作错误只回滚该 SAVEPOINT,不标 busy。 + - 原因:满足每秒约 200 条写入的落盘合并需求。 + - 备选方案:按固定时间窗无条件合并。 + - 影响:调用方需用 `errors.Is(err, store.ErrBusy)` 映射协议 `busy`;`/readyz` 读 `DB.Ready`。 + +2. **调用方 context 取消与已入队任务** + - 原条款:未规定入队后取消。 + - 实际做法:入队前检查 ctx;批次执行前再检查;若调用方在等待结果时取消,最多再等 30 秒取结果以免泄漏。 + - 原因:写 goroutine 仍可能已执行该操作,不能静默丢结果。 + - 备选方案:取消即从队列摘除(需可取消数据结构)。 + - 影响:极端取消场景下调用方可能多等一会儿。 + ## 连接 N 暂无。 diff --git a/internal/store/queue.go b/internal/store/queue.go index 4286d1a..a197194 100644 --- a/internal/store/queue.go +++ b/internal/store/queue.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "errors" + "fmt" "sync" + "sync/atomic" "time" ) @@ -14,25 +16,45 @@ var ErrQueueClosed = errors.New("store: write queue closed") // ErrBusy 表示写库基础设施失败(可映射为协议 busy;/readyz 应失败)。 var ErrBusy = errors.New("store: busy") +const ( + maxBatchOps = 256 + batchWait = 2 * time.Millisecond + queueBuffSize = 1024 +) + // WriteFunc 在单个写事务中执行的操作。 type WriteFunc func(tx *sql.Tx) error -// Queue 写入队列:提交一个写操作并拿到结果。 -// -// P1 仍为一操作一事务;P2 换成合并提交(最多 256 / 2ms + SAVEPOINT)。 +type writeJob struct { + ctx context.Context + fn WriteFunc + res chan error +} + +// Queue 写入队列:写 goroutine 合并提交(最多 256 个或凑满 2ms),每操作用 SAVEPOINT 隔离。 type Queue struct { db *sql.DB + ch chan writeJob + done chan struct{} + closed atomic.Bool + mu sync.Mutex - closed bool - inflight int ready bool lastWriteErr error + pending int } -// NewQueue 创建简单写入队列(一操作一事务)。 +// NewQueue 创建合并写入队列并启动写 goroutine。 func NewQueue(db *sql.DB) *Queue { - return &Queue{db: db, ready: true} + q := &Queue{ + db: db, + ch: make(chan writeJob, queueBuffSize), + done: make(chan struct{}), + ready: true, + } + go q.loop() + return q } // Do 提交写操作并等待提交结果。 @@ -40,37 +62,180 @@ func (q *Queue) Do(ctx context.Context, fn WriteFunc) error { if fn == nil { return errors.New("store: nil write func") } - q.mu.Lock() - if q.closed { - q.mu.Unlock() + if q.closed.Load() { return ErrQueueClosed } - q.inflight++ - q.mu.Unlock() - - defer func() { - q.mu.Lock() - q.inflight-- - q.mu.Unlock() - }() - if err := ctx.Err(); err != nil { return err } - tx, err := q.db.BeginTx(ctx, nil) + job := writeJob{ctx: ctx, fn: fn, res: make(chan error, 1)} + q.mu.Lock() + q.pending++ + q.mu.Unlock() + select { + case q.ch <- job: + case <-ctx.Done(): + q.mu.Lock() + q.pending-- + q.mu.Unlock() + return ctx.Err() + case <-q.done: + q.mu.Lock() + q.pending-- + q.mu.Unlock() + return ErrQueueClosed + } + select { + case err := <-job.res: + return err + case <-ctx.Done(): + // 操作可能仍在队列中执行;结果通道仍会被写端关闭式填入。 + select { + case err := <-job.res: + if err != nil { + return err + } + return ctx.Err() + case <-time.After(30 * time.Second): + return ctx.Err() + } + } +} + +func (q *Queue) loop() { + defer close(q.done) + for { + job, ok := <-q.ch + if !ok { + return + } + batch := []writeJob{job} + timer := time.NewTimer(batchWait) + collect: + for len(batch) < maxBatchOps { + select { + case j, ok := <-q.ch: + if !ok { + break collect + } + batch = append(batch, j) + case <-timer.C: + break collect + } + } + timer.Stop() + q.runBatch(batch) + } +} + +func (q *Queue) runBatch(batch []writeJob) { + defer func() { + q.mu.Lock() + q.pending -= len(batch) + if q.pending < 0 { + q.pending = 0 + } + q.mu.Unlock() + }() + + // 过滤已取消的任务。 + active := make([]writeJob, 0, len(batch)) + for _, j := range batch { + if err := j.ctx.Err(); err != nil { + j.res <- err + continue + } + active = append(active, j) + } + if len(active) == 0 { + return + } + + tx, err := q.db.BeginTx(context.Background(), nil) if err != nil { q.markBusy(err) - return errors.Join(ErrBusy, err) + for _, j := range active { + j.res <- errors.Join(ErrBusy, err) + } + return } - if err := fn(tx); err != nil { - _ = tx.Rollback() - return err + + type outcome struct { + job writeJob + opErr error + success bool } + outcomes := make([]outcome, 0, len(active)) + for i, j := range active { + sp := fmt.Sprintf("sp_%d", i) + if _, err := tx.Exec("SAVEPOINT " + sp); err != nil { + _ = tx.Rollback() + q.markBusy(err) + for _, o := range outcomes { + if o.success { + o.job.res <- errors.Join(ErrBusy, err) + } + } + for _, rest := range active[i:] { + rest.res <- errors.Join(ErrBusy, err) + } + return + } + opErr := j.fn(tx) + if opErr != nil { + if _, rbErr := tx.Exec("ROLLBACK TO " + sp); rbErr != nil { + _ = tx.Rollback() + q.markBusy(rbErr) + for _, o := range outcomes { + if o.success { + o.job.res <- errors.Join(ErrBusy, rbErr) + } + } + j.res <- opErr + for _, rest := range active[i+1:] { + rest.res <- errors.Join(ErrBusy, rbErr) + } + return + } + _, _ = tx.Exec("RELEASE " + sp) + outcomes = append(outcomes, outcome{job: j, opErr: opErr, success: false}) + continue + } + if _, err := tx.Exec("RELEASE " + sp); err != nil { + _ = tx.Rollback() + q.markBusy(err) + for _, o := range outcomes { + if o.success { + o.job.res <- errors.Join(ErrBusy, err) + } + } + j.res <- errors.Join(ErrBusy, err) + for _, rest := range active[i+1:] { + rest.res <- errors.Join(ErrBusy, err) + } + return + } + outcomes = append(outcomes, outcome{job: j, success: true}) + } + if err := tx.Commit(); err != nil { q.markBusy(err) - return errors.Join(ErrBusy, err) + for _, o := range outcomes { + if o.success { + o.job.res <- errors.Join(ErrBusy, err) + } else { + o.job.res <- o.opErr + } + } + return + } + for _, o := range outcomes { + if o.success { + o.job.res <- nil + } else { + o.job.res <- o.opErr + } } - return nil } func (q *Queue) markBusy(err error) { @@ -94,20 +259,20 @@ func (q *Queue) LastWriteError() error { return q.lastWriteErr } -// Len 返回进行中的写操作数。 +// Len 返回排队与批次中的写操作数。 func (q *Queue) Len() int { q.mu.Lock() defer q.mu.Unlock() - return q.inflight + return q.pending } -// Drain 等待已进行中的写操作完成,或 ctx 取消。 +// Drain 等待已入队操作完成,或 ctx 取消。关闭后也会等到 loop 退出。 func (q *Queue) Drain(ctx context.Context) error { ticker := time.NewTicker(5 * time.Millisecond) defer ticker.Stop() for { q.mu.Lock() - n := q.inflight + n := q.pending q.mu.Unlock() if n == 0 { return nil @@ -120,10 +285,12 @@ func (q *Queue) Drain(ctx context.Context) error { } } -// Close 关闭队列,之后 Do 返回 ErrQueueClosed。 +// Close 关闭队列:不再接受新任务,并等待写 goroutine 处理完已入队任务后退出。 func (q *Queue) Close() error { - q.mu.Lock() - defer q.mu.Unlock() - q.closed = true + if q.closed.Swap(true) { + return nil + } + close(q.ch) + <-q.done return nil } diff --git a/internal/store/queue_test.go b/internal/store/queue_test.go new file mode 100644 index 0000000..567daaf --- /dev/null +++ b/internal/store/queue_test.go @@ -0,0 +1,175 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +func TestWriteQueueSavepointIsolatesFailure(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := Open(dir, "FULL") + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + + ctx := context.Background() + var wg sync.WaitGroup + wg.Add(2) + errOK := make(chan error, 1) + errBad := make(chan error, 1) + + go func() { + defer wg.Done() + errOK <- db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, e := tx.Exec( + `INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?)`, + "k_ok", "1", time.Now().UnixMilli(), + ) + return e + }) + }() + go func() { + defer wg.Done() + // 稍等,尽量与成功操作进同一批。 + time.Sleep(500 * time.Microsecond) + errBad <- db.Queue.Do(ctx, func(tx *sql.Tx) error { + return errors.New("forced op failure") + }) + }() + wg.Wait() + + if e := <-errOK; e != nil { + t.Fatalf("ok op: %v", e) + } + if e := <-errBad; e == nil || e.Error() != "forced op failure" { + t.Fatalf("bad op: %v", e) + } + + var value string + if scanErr := db.Read.QueryRow(`SELECT value FROM settings WHERE key = ?`, "k_ok").Scan(&value); scanErr != nil { + t.Fatalf("ok row missing: %v", scanErr) + } + if value != "1" { + t.Fatalf("value=%q", value) + } + if !db.Queue.IsReady() { + t.Fatal("op failure should not mark queue busy") + } +} + +func TestWriteQueueBatchCommit(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := Open(dir, "FULL") + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + + ctx := context.Background() + const n = 32 + var wg sync.WaitGroup + wg.Add(n) + errs := make([]error, n) + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + key := fmt.Sprintf("batch_%d", i) + errs[i] = db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, e := tx.Exec( + `INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?)`, + key, "1", time.Now().UnixMilli(), + ) + return e + }) + }() + } + wg.Wait() + for i, e := range errs { + if e != nil { + t.Fatalf("op %d: %v", i, e) + } + } + var count int + if err := db.Read.QueryRow(`SELECT COUNT(*) FROM settings WHERE key LIKE 'batch_%'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != n { + t.Fatalf("count=%d want %d", count, n) + } +} + +func TestWriteQueueNoBacklogAt200PerSec(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := Open(dir, "FULL") + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + + ctx := context.Background() + const total = 200 + start := time.Now() + var wg sync.WaitGroup + wg.Add(total) + for i := 0; i < total; i++ { + i := i + go func() { + defer wg.Done() + key := fmt.Sprintf("load_%d", i) + if e := db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, e := tx.Exec( + `INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?)`, + key, "1", time.Now().UnixMilli(), + ) + return e + }); e != nil { + t.Errorf("op %d: %v", i, e) + } + }() + } + wg.Wait() + elapsed := time.Since(start) + if db.Queue.Len() != 0 { + t.Fatalf("queue backlog len=%d", db.Queue.Len()) + } + // 200 条并发写入应在数秒内完成(合并提交);过长则合并未生效。 + if elapsed > 5*time.Second { + t.Fatalf("200 writes took %s, queue likely not merging well", elapsed) + } +} + +func BenchmarkWriteQueue200PerSec(b *testing.B) { + dir := b.TempDir() + db, err := Open(dir, "FULL") + if err != nil { + b.Fatal(err) + } + defer func() { _ = db.Close() }() + + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + key := fmt.Sprintf("bench_%d", i) + if err := db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, e := tx.Exec( + `INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?) +ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, "1", time.Now().UnixMilli(), + ) + return e + }); err != nil { + b.Fatal(err) + } + } +}