feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确
Motivation: 持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。 Changes: * 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化 * 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入 * 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真 * 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖 * 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
positionKeyPrefix = "notify:position:"
|
||||
appliedKeyPrefix = "notify:position:applied:"
|
||||
positionTTL = 30 * 24 * time.Hour
|
||||
appliedTTL = 7 * 24 * time.Hour
|
||||
storeTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type persistedState struct {
|
||||
Avg float64 `json:"avg"`
|
||||
Size float64 `json:"size"`
|
||||
Mode int `json:"mode"`
|
||||
}
|
||||
|
||||
type positionStore interface {
|
||||
load(key string) (*state, error)
|
||||
commit(key string, st *state, del bool, signalID string, snap Snapshot) error
|
||||
loadApplied(signalID string) (Snapshot, bool, error)
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
positions map[string]*state
|
||||
applied map[string]Snapshot
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{
|
||||
positions: make(map[string]*state),
|
||||
applied: make(map[string]Snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memoryStore) load(key string) (*state, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.positions[key], nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) loadApplied(signalID string) (Snapshot, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
snap, ok := m.applied[signalID]
|
||||
return snap, ok, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if del {
|
||||
delete(m.positions, key)
|
||||
} else if st != nil {
|
||||
m.positions[key] = st
|
||||
}
|
||||
if signalID != "" {
|
||||
m.applied[signalID] = snap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type kvClient interface {
|
||||
GetRaw(ctx context.Context, key string) ([]byte, error)
|
||||
TxWrite(ctx context.Context, writes []cache.KVWrite) error
|
||||
}
|
||||
|
||||
type redisStore struct {
|
||||
c kvClient
|
||||
}
|
||||
|
||||
func newRedisStore(c *cache.Cache) positionStore {
|
||||
if c == nil {
|
||||
return newMemoryStore()
|
||||
}
|
||||
return &redisStore{c: c}
|
||||
}
|
||||
|
||||
func (s *redisStore) ctx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), storeTimeout)
|
||||
}
|
||||
|
||||
func (s *redisStore) load(key string) (*state, error) {
|
||||
ctx, cancel := s.ctx()
|
||||
defer cancel()
|
||||
b, err := s.c.GetRaw(ctx, positionKeyPrefix+key)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var p persistedState
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state{avg: p.Avg, size: p.Size, mode: mode(p.Mode)}, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) loadApplied(signalID string) (Snapshot, bool, error) {
|
||||
if signalID == "" {
|
||||
return Snapshot{}, false, nil
|
||||
}
|
||||
ctx, cancel := s.ctx()
|
||||
defer cancel()
|
||||
b, err := s.c.GetRaw(ctx, appliedKeyPrefix+signalID)
|
||||
if err != nil || len(b) == 0 {
|
||||
return Snapshot{}, false, err
|
||||
}
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return Snapshot{}, false, err
|
||||
}
|
||||
return snap, true, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
|
||||
ctx, cancel := s.ctx()
|
||||
defer cancel()
|
||||
writes := make([]cache.KVWrite, 0, 2)
|
||||
posKey := positionKeyPrefix + key
|
||||
if del {
|
||||
writes = append(writes, cache.KVWrite{Key: posKey, Delete: true})
|
||||
} else if st != nil {
|
||||
b, err := json.Marshal(persistedState{Avg: st.avg, Size: st.size, Mode: int(st.mode)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writes = append(writes, cache.KVWrite{Key: posKey, Val: b, TTL: positionTTL})
|
||||
}
|
||||
if signalID != "" {
|
||||
b, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writes = append(writes, cache.KVWrite{Key: appliedKeyPrefix + signalID, Val: b, TTL: appliedTTL})
|
||||
}
|
||||
return s.c.TxWrite(ctx, writes)
|
||||
}
|
||||
Reference in New Issue
Block a user