feat: 实现写入队列合并事务与 SAVEPOINT 隔离
This commit is contained in:
@@ -182,6 +182,22 @@
|
|||||||
- 备选方案:先关 listener、Drain、再 Shutdown。
|
- 备选方案:先关 listener、Drain、再 Shutdown。
|
||||||
- 影响:有 MQTT 长连接后需 N/P 联调停机路径。
|
- 影响:有 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
|
## 连接 N
|
||||||
|
|
||||||
暂无。
|
暂无。
|
||||||
|
|||||||
+201
-34
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,25 +16,45 @@ var ErrQueueClosed = errors.New("store: write queue closed")
|
|||||||
// ErrBusy 表示写库基础设施失败(可映射为协议 busy;/readyz 应失败)。
|
// ErrBusy 表示写库基础设施失败(可映射为协议 busy;/readyz 应失败)。
|
||||||
var ErrBusy = errors.New("store: busy")
|
var ErrBusy = errors.New("store: busy")
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxBatchOps = 256
|
||||||
|
batchWait = 2 * time.Millisecond
|
||||||
|
queueBuffSize = 1024
|
||||||
|
)
|
||||||
|
|
||||||
// WriteFunc 在单个写事务中执行的操作。
|
// WriteFunc 在单个写事务中执行的操作。
|
||||||
type WriteFunc func(tx *sql.Tx) error
|
type WriteFunc func(tx *sql.Tx) error
|
||||||
|
|
||||||
// Queue 写入队列:提交一个写操作并拿到结果。
|
type writeJob struct {
|
||||||
//
|
ctx context.Context
|
||||||
// P1 仍为一操作一事务;P2 换成合并提交(最多 256 / 2ms + SAVEPOINT)。
|
fn WriteFunc
|
||||||
|
res chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue 写入队列:写 goroutine 合并提交(最多 256 个或凑满 2ms),每操作用 SAVEPOINT 隔离。
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
|
||||||
|
ch chan writeJob
|
||||||
|
done chan struct{}
|
||||||
|
closed atomic.Bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
closed bool
|
|
||||||
inflight int
|
|
||||||
ready bool
|
ready bool
|
||||||
lastWriteErr error
|
lastWriteErr error
|
||||||
|
pending int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQueue 创建简单写入队列(一操作一事务)。
|
// NewQueue 创建合并写入队列并启动写 goroutine。
|
||||||
func NewQueue(db *sql.DB) *Queue {
|
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 提交写操作并等待提交结果。
|
// Do 提交写操作并等待提交结果。
|
||||||
@@ -40,37 +62,180 @@ func (q *Queue) Do(ctx context.Context, fn WriteFunc) error {
|
|||||||
if fn == nil {
|
if fn == nil {
|
||||||
return errors.New("store: nil write func")
|
return errors.New("store: nil write func")
|
||||||
}
|
}
|
||||||
q.mu.Lock()
|
if q.closed.Load() {
|
||||||
if q.closed {
|
|
||||||
q.mu.Unlock()
|
|
||||||
return ErrQueueClosed
|
return ErrQueueClosed
|
||||||
}
|
}
|
||||||
q.inflight++
|
|
||||||
q.mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
q.mu.Lock()
|
|
||||||
q.inflight--
|
|
||||||
q.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
q.markBusy(err)
|
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()
|
type outcome struct {
|
||||||
return err
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
q.markBusy(err)
|
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) {
|
func (q *Queue) markBusy(err error) {
|
||||||
@@ -94,20 +259,20 @@ func (q *Queue) LastWriteError() error {
|
|||||||
return q.lastWriteErr
|
return q.lastWriteErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Len 返回进行中的写操作数。
|
// Len 返回排队与批次中的写操作数。
|
||||||
func (q *Queue) Len() int {
|
func (q *Queue) Len() int {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
return q.inflight
|
return q.pending
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain 等待已进行中的写操作完成,或 ctx 取消。
|
// Drain 等待已入队操作完成,或 ctx 取消。关闭后也会等到 loop 退出。
|
||||||
func (q *Queue) Drain(ctx context.Context) error {
|
func (q *Queue) Drain(ctx context.Context) error {
|
||||||
ticker := time.NewTicker(5 * time.Millisecond)
|
ticker := time.NewTicker(5 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
n := q.inflight
|
n := q.pending
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -120,10 +285,12 @@ func (q *Queue) Drain(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close 关闭队列,之后 Do 返回 ErrQueueClosed。
|
// Close 关闭队列:不再接受新任务,并等待写 goroutine 处理完已入队任务后退出。
|
||||||
func (q *Queue) Close() error {
|
func (q *Queue) Close() error {
|
||||||
q.mu.Lock()
|
if q.closed.Swap(true) {
|
||||||
defer q.mu.Unlock()
|
return nil
|
||||||
q.closed = true
|
}
|
||||||
|
close(q.ch)
|
||||||
|
<-q.done
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user