diff --git a/write_behind_queue/README.md b/write_behind_queue/README.md new file mode 100644 index 0000000..6d4fcd2 --- /dev/null +++ b/write_behind_queue/README.md @@ -0,0 +1,84 @@ +# write-behind-queue 组件 + +高性能异步批量写入组件,专为游戏服务器设计,支持批量写入优化和优雅关闭。 + +## 核心特性 + +- ✅ **异步批量写入** - 将频繁的小写入合并为大批量,减少数据库 IO +- ✅ **智能去重合并** - 相同 key 只保留最新值,大幅减少写入次数 +- ✅ **多队列分片** - 按 PlayerID 哈希分流,避免热点竞争 +- ✅ **定时+容量触发** - 批量大小或时间间隔任一到达即刷新 +- ✅ **优雅关闭** - 保证进程退出时所有数据安全落盘 +- ✅ **可插拔后端** - 支持 Redis/MongoDB/MySQL 等任意数据库 + +## Install + +### Prerequisites +- GO >= 1.21 + +### Using go get +```bash +go get github.com/cherry-game/examples/demo_cluster/internal/component/write_behind_queue@latest + +Quick Start +import writebehindqueue "github.com/cherry-game/examples/demo_cluster/internal/component/write_behind_queue" + +package main + +import ( + "time" + + "github.com/cherry-game/cherry" + cherryRedis "github.com/cherry-game/components/redis" + writebehindqueue "github.com/cherry-game/examples/demo_cluster/internal/component/write_behind_queue" +) + +func main() { + // defaultConfig := writebehindqueue.DefaultTableConfig() + // 1. 配置各业务表的队列参数 + configs := map[string]writebehindqueue.TableConfig{ + "player_data": { + QueueCount: 4, // 该表开 4 个 worker 并发处理 + QueueSize: 20480, // Channel 缓冲区大小 + BulkSize: 500, // 每 500 条批量写入 + FlushInterval: 10 * time.Second, // 或 5 秒强制刷新 + }, + "room_data": { + QueueCount: 2, + QueueSize: 1024, + BulkSize: 50, + FlushInterval: 20 * time.Second, + }, + } + + // 2. 初始化 Redis Backend + redisComp := cherryRedis.NewRedisCompent() + redisComp.Init() // 注意:需要手动初始化 + backend := writebehindqueue.NewRedisBackend(redisComp.GetDb()) + + // 3. 注册组件 + wbQueue := writebehindqueue.NewDBWriteQueueComponent(configs, backend) + cherry.RegisterComponent(wbQueue) + + // 4. 启动应用 + cherry.Run() +} + +提交写入任务 + +// 获取组件实例 +wbQueue := app.Find("db_write_queue").(*writebehindqueue.DBWriteQueueComponent) + +// 提交任务 +task := &writebehindqueue.DbWriteTask{ + Table: "player_data", // 目标表名 + ExtraKeyId: "room_123", // 可选的额外 key(如 roomId) + PlayerID: 10001, // 用于分片的玩家 ID + OpType: writebehindqueue.OpUpdate, + Data: playerData, // 数据对象(需深拷贝) +} + +success := wbQueue.SubmitTask(task) +if !success { + // 处理失败情况(队列满/组件已停止) +} diff --git a/write_behind_queue/component.go b/write_behind_queue/component.go new file mode 100644 index 0000000..8716738 --- /dev/null +++ b/write_behind_queue/component.go @@ -0,0 +1,242 @@ +package writebehindqueue + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "time" + + cfacade "github.com/cherry-game/cherry/facade" + clog "github.com/cherry-game/cherry/logger" +) + +// OpType 数据库操作类型定义 +type OpType int + +const ( + OpInsert OpType = 1 + OpUpdate OpType = 2 + OpDelete OpType = 3 +) + +// 通用队列数据任务 DbWriteTask +type DbWriteTask struct { + Table string // 目标表名 + ExtraKeyId string // 额外的key 比如roomId + PlayerID int32 // 玩家Id + OpType OpType // 操作类型 + Data interface{} // 玩家数据深拷贝的 +} + +// BatchSaver 具体的数据库批量写入执行者(解耦 GORM/MongoDB 等) +type BatchSaver interface { + SaveBatch(table string, tasks []*DbWriteTask) error +} +type TableConfig struct { + QueueCount int // 该表开启的队列(Worker)数量,建议设为跟主机的物理 CPU 核心数相同,不过根据实际数据量 + QueueSize int // 单个队列的 Channel 缓冲大小,根据表数据的大小 + BulkSize int // 单次批量写入的最大条数(去重后) + FlushInterval time.Duration // 强制刷入数据库的时间间隔,根据数据重要性 + StopBulkSize int // 停服的时候单次批量写入的最大条数(去重后),为了快速同步 +} + +func DefaultTableConfig() *TableConfig { + return &TableConfig{ + QueueCount: 2, + QueueSize: 10240, + BulkSize: 200, + FlushInterval: 10 * time.Second, + StopBulkSize: 2000, + } +} + +type DBWriteQueueComponent struct { + cfacade.Component + name string + config map[string]TableConfig // 表配置 + Saver PersistenceBackend // 具体的底层数据库保存实现 + workers map[string][]*worker // 运行中的 workers: table -> []*worker + wg sync.WaitGroup + ctx context.Context + cancelFunc context.CancelFunc + running bool + mu sync.RWMutex +} + +func NewDBWriteQueueComponent(config map[string]TableConfig, saver PersistenceBackend) *DBWriteQueueComponent { + ctx, cancel := context.WithCancel(context.Background()) + if saver == nil { + clog.Panicf(fmt.Sprintf("[%s] Init failed: BatchSaver is nil", "db_write_queue")) + } + return &DBWriteQueueComponent{ + config: config, + workers: make(map[string][]*worker), + ctx: ctx, + cancelFunc: cancel, + Saver: saver, + } +} + +func (d *DBWriteQueueComponent) Name() string { + return "db_write_queue" +} + +func (d *DBWriteQueueComponent) Init() { + d.mu.Lock() + defer d.mu.Unlock() + + if d.running { + return + } + + d.running = true + for table, tCfg := range d.config { + for i := 0; i < tCfg.QueueCount; i++ { + w := &worker{ + queue: make(chan *DbWriteTask, tCfg.QueueSize), + table: table, + bulkSize: tCfg.BulkSize, + stopBulkSize: tCfg.StopBulkSize, + flushInterval: tCfg.FlushInterval, + batchMap: make(map[string]*DbWriteTask), + } + d.workers[table] = append(d.workers[table], w) + d.wg.Add(1) + go func(w *worker) { + defer d.wg.Done() + clog.Infof("Init work") + w.run(d.ctx, d.Saver) + }(w) + } + clog.Infof("[%s] Init table [%s] with %d queues successfully", d.Name(), table, tCfg.QueueCount) + } + clog.Infof("[%s] component init successfully", d.Name()) +} + +func (d *DBWriteQueueComponent) OnStop() { + d.mu.Lock() + d.running = false + d.mu.Unlock() + clog.Infof("[%s] Stopping, closing channels and flushing tasks...", d.Name()) + // 1. 停止接受新任务并关闭所有 worker 的 channel + d.mu.RLock() + for _, workers := range d.workers { + for _, w := range workers { + w.bulkSize = w.stopBulkSize + close(w.queue) // 关闭通道,通知 worker 消费完剩余积压 + } + } + d.mu.RUnlock() + // 等待所有 worker 处理完积压的任务 + // case task, ok := <-w.queue: + // if !ok + // 一定不能先 d.cancelFunc(),再d.wg.Wait(),可能chan中的消息还没有处理接受完, + d.wg.Wait() + // 2. 通知 context 结束(如果是阻塞等待的 timer 也会被快速释放) + d.cancelFunc() + + clog.Infof("[%s] Stopped, all tasks flushed to DB safely.", d.Name()) +} + +func (d *DBWriteQueueComponent) SubmitTask(task *DbWriteTask) bool { + d.mu.RLock() + defer d.mu.RUnlock() + if !d.running { + clog.Warnf("[%s] SubmitTask failed: component is stopped. task: %+v", d.Name(), task) + return false + } + if _, ok := d.workers[task.Table]; !ok { + clog.Errorf("[%s] SubmitTask failed: table [%s] queue not registered", d.Name(), task.Table) + return false + } + // 同一个玩家的数据必须放在同一个桶里 + workerIndex := task.PlayerID % int32(len(d.workers[task.Table])) + select { + case d.workers[task.Table][workerIndex].queue <- task: + return true + default: + // 当队列爆满时的降级机制(生产环境可根据业务需求选择:阻塞/丢弃/落日志告警),或者重启扩大队列 + clog.Errorf("[%s] Queue is full for table [%s], player_id: %d", d.Name(), task.Table, task.PlayerID) + return false + } +} + +type worker struct { + queue chan *DbWriteTask + table string + bulkSize int + stopBulkSize int + flushInterval time.Duration + batchMap map[string]*DbWriteTask // 消费端用来合并去重的内存 Map +} + +func (w *worker) run(ctx context.Context, Saver PersistenceBackend) { + timer := time.NewTimer(w.flushInterval) + defer timer.Stop() + + for { + select { + case task, ok := <-w.queue: + if !ok { + w.flush(ctx, Saver) + clog.Infof("dbqueue channel closed") + return + } + key := w.getHashKey(w.table, strconv.FormatInt(int64(task.PlayerID), 10), task.ExtraKeyId) + w.batchMap[key] = task + clog.Infof("dbqueue1 worker batchMap len: %d bulkSize: %d", len(w.batchMap), w.bulkSize) + if len(w.batchMap) >= w.bulkSize { + clog.Infof("dbqueue2 worker batchMap len: %d", len(w.batchMap), w.bulkSize) + w.flush(ctx, Saver) + } + case <-ctx.Done(): + // 强制刷入剩余数据 + w.flush(ctx, Saver) + clog.Infof("dbqueue worker stoped") + return + case <-timer.C: + // 定时刷入数据 + clog.Infof("time loop dbqueue worker batchMap len: %d", len(w.batchMap)) + w.flush(ctx, Saver) + timer.Reset(w.flushInterval) + } + } +} + +func (w *worker) flush(ctx context.Context, Saver PersistenceBackend) { + if len(w.batchMap) == 0 { + return + } + tasks := make([]*DbWriteTask, 0, len(w.batchMap)) + for _, task := range w.batchMap { + tasks = append(tasks, task) + } + + if len(tasks) > 0 { + // 【并发安全保护】:如果传入的 ctx 已经被取消(比如系统收到了 hard-kill 信号等) + // 则创建一个带超时时间的全新独立上下文,确保 Redis 的最后一次 Pipeline 能够成功执行 + writeCtx := ctx + if ctx.Err() != nil { + var cancel context.CancelFunc + writeCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + } + + err := Saver.BatchSave(writeCtx, tasks) + if err != nil { + clog.Errorf("save batch failed. err = %v", err) + return // 发生错误时直接返回,不要清空 map,留到下次 flush 重试 + } + } + + // 清空map + for k := range w.batchMap { + delete(w.batchMap, k) + } +} + +func (w *worker) getHashKey(tableName string, key ...string) string { + return strings.Join(append([]string{tableName}, key...), ":") +} diff --git a/write_behind_queue/db_server.go b/write_behind_queue/db_server.go new file mode 100644 index 0000000..649d38d --- /dev/null +++ b/write_behind_queue/db_server.go @@ -0,0 +1,17 @@ +package writebehindqueue + +import "context" + +// ==================== 数据库接口抽象 ==================== + +// PersistenceBackend 持久化后端接口,使用者实现( Redis/Mongo/MySQL等) +type PersistenceBackend interface { + // Save 保存 + Save(ctx context.Context, data *DbWriteTask) error + + // BatchSave 批量保存数据 + BatchSave(ctx context.Context, tasks []*DbWriteTask) error + + // Load 加载单条数据 + Load(ctx context.Context, table, key, ExtraKeyId string) ([]byte, error) +} diff --git a/write_behind_queue/go.mod b/write_behind_queue/go.mod new file mode 100644 index 0000000..ece8d6e --- /dev/null +++ b/write_behind_queue/go.mod @@ -0,0 +1,21 @@ +module github.com/cherry-game/components/write_behind_queue + +go 1.26.4 + +require ( + github.com/cherry-game/cherry v1.5.3 + github.com/redis/go-redis/v9 v9.21.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/lestrrat-go/strftime v1.0.6 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/write_behind_queue/go.sum b/write_behind_queue/go.sum new file mode 100644 index 0000000..6978439 --- /dev/null +++ b/write_behind_queue/go.sum @@ -0,0 +1,53 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cherry-game/cherry v1.5.3 h1:4II8cLIkCmgUt96nfPfZdxuPmXhBkHD3Zf6GxQTUiOY= +github.com/cherry-game/cherry v1.5.3/go.mod h1:Bj1g03vFeBYFSgH1QQi/Czul0g+aSTz/ugQLB4fL5JA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8= +github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= +github.com/lestrrat-go/strftime v1.0.6 h1:CFGsDEt1pOpFNU+TJB0nhz9jl+K0hZSLE205AhTIGQQ= +github.com/lestrrat-go/strftime v1.0.6/go.mod h1:f7jQKgV5nnJpYgdEasS+/y7EsTb8ykN2z68n3TtcTaw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/write_behind_queue/redis_server.go b/write_behind_queue/redis_server.go new file mode 100644 index 0000000..fe7da19 --- /dev/null +++ b/write_behind_queue/redis_server.go @@ -0,0 +1,73 @@ +package writebehindqueue + +import ( + "context" + "fmt" + "strconv" + "strings" + + clog "github.com/cherry-game/cherry/logger" + "github.com/redis/go-redis/v9" +) + +// redis 数据直接存hash +type RedisBackend struct { + client redis.UniversalClient +} + +func NewRedisBackend(client redis.UniversalClient) *RedisBackend { + if client == nil { + clog.Panicf("[RedisBackend] Redis client is required") + } + return &RedisBackend{client: client} +} + +func (r *RedisBackend) Save(ctx context.Context, data *DbWriteTask) error { + return r.BatchSave(ctx, []*DbWriteTask{data}) +} + +// BatchSave 批量保存数据 +func (r *RedisBackend) BatchSave(ctx context.Context, tasks []*DbWriteTask) error { + if len(tasks) == 0 { + return nil + } + clog.Infof("dbqueue flush len: %d", len(tasks)) + // 1. 开启 Pipeline 管道,将这批任务的所有网络 I/O 合并为一次发送 + pipe := r.client.Pipeline() + for _, task := range tasks { + hashKey := r.getHashKey(task.Table) + FieldKey := r.getField(strconv.FormatInt(int64(task.PlayerID), 10), task.ExtraKeyId) + if task.OpType == OpDelete { + pipe.HDel(ctx, hashKey, FieldKey) + } else { + // 2. 序列化数据(Redis Hash 字段只能存储 String/Binary) + pipe.HSet(ctx, hashKey, FieldKey, task.Data) + } + } + // 3. 一次性提交并执行管道内的所有命令 + _, err := pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("redis pipeline exec failed: %w", err) + } + + return nil +} + +// Load 加载单条数据 +func (r *RedisBackend) Load(ctx context.Context, table, key, ExtraKeyId string) ([]byte, error) { + if len(table) == 0 || len(key) == 0 { + return nil, fmt.Errorf("redis batch save json marshal failed,hashKey : %s, filedKey: %s", table, key) + } + hashKey := r.getHashKey(table) + fieldKey := r.getField(key, ExtraKeyId) + return r.client.HGet(ctx, hashKey, fieldKey).Bytes() +} + +func (r *RedisBackend) getField(key ...string) string { + return strings.Join(key, ":") +} + +// 加上tableName hashkey +func (r *RedisBackend) getHashKey(tableName string) string { + return fmt.Sprint("player_data", ":", tableName) +}