fix(go): singleflight tdengine child table ensures
This commit is contained in:
@@ -22,6 +22,8 @@ Go 版本车辆数据接入链路已经作为生产主链路运行在 ECS `115.2
|
|||||||
|
|
||||||
2026-07-03 之后,`nats-fast-writer` 的快速路径是 TDengine 批写 + Redis 批量 pipeline:一个 NATS fetch batch 先批量写 TDengine,再用 `FastUpdateBatch` 把实时 KV、在线 TTL、last_seen 和协议索引合并到一次 Redis pipeline,最后逐条 ack NATS。
|
2026-07-03 之后,`nats-fast-writer` 的快速路径是 TDengine 批写 + Redis 批量 pipeline:一个 NATS fetch batch 先批量写 TDengine,再用 `FastUpdateBatch` 把实时 KV、在线 TTL、last_seen 和协议索引合并到一次 Redis pipeline,最后逐条 ack NATS。
|
||||||
|
|
||||||
|
TDengine writer 的 raw/location 子表创建有进程内单飞保护:同一子表 key 并发首次写入时,只有一个 goroutine 执行 `CREATE TABLE IF NOT EXISTS` 和 tag 更新,其他 goroutine 等待结果后继续 INSERT,避免 10W 车辆启动或回放时对 TDengine 形成重复 DDL 风暴。
|
||||||
|
|
||||||
## 服务和端口
|
## 服务和端口
|
||||||
|
|
||||||
ECS:`115.29.187.205`
|
ECS:`115.29.187.205`
|
||||||
|
|||||||
@@ -40,8 +40,45 @@ type payloadChunk struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tableCache struct {
|
type tableCache struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seen map[string]struct{}
|
seen map[string]struct{}
|
||||||
|
inflight map[string]*tableEnsure
|
||||||
|
}
|
||||||
|
|
||||||
|
type tableEnsure struct {
|
||||||
|
done chan struct{}
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tableCache) doOnce(key string, fn func() error) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if _, ok := c.seen[key]; ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.inflight == nil {
|
||||||
|
c.inflight = map[string]*tableEnsure{}
|
||||||
|
}
|
||||||
|
if entry, ok := c.inflight[key]; ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
<-entry.done
|
||||||
|
return entry.err
|
||||||
|
}
|
||||||
|
entry := &tableEnsure{done: make(chan struct{})}
|
||||||
|
c.inflight[key] = entry
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
err := fn()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if err == nil {
|
||||||
|
c.seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
entry.err = err
|
||||||
|
delete(c.inflight, key)
|
||||||
|
close(entry.done)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWriter(exec Execer) *Writer {
|
func NewWriter(exec Execer) *Writer {
|
||||||
@@ -230,53 +267,39 @@ VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
|
|||||||
|
|
||||||
func (w *Writer) ensureRawChild(ctx context.Context, table string, stable string, env envelope.FrameEnvelope) error {
|
func (w *Writer) ensureRawChild(ctx context.Context, table string, stable string, env envelope.FrameEnvelope) error {
|
||||||
key := stable + "." + table
|
key := stable + "." + table
|
||||||
w.cache.mu.Lock()
|
return w.cache.doOnce(key, func() error {
|
||||||
_, ok := w.cache.seen[key]
|
statement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s TAGS ('%s', '%s', '%s', '%s', '%s')",
|
||||||
w.cache.mu.Unlock()
|
w.qualify(table), w.qualify(stable),
|
||||||
if ok {
|
quote(string(env.Protocol)),
|
||||||
return nil
|
quote(env.VehicleKey()),
|
||||||
}
|
quote(env.VIN),
|
||||||
statement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s TAGS ('%s', '%s', '%s', '%s', '%s')",
|
quote(normalizePhoneTag(env.Phone)),
|
||||||
w.qualify(table), w.qualify(stable),
|
quote(env.DeviceID))
|
||||||
quote(string(env.Protocol)),
|
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
||||||
quote(env.VehicleKey()),
|
|
||||||
quote(env.VIN),
|
|
||||||
quote(normalizePhoneTag(env.Phone)),
|
|
||||||
quote(env.DeviceID))
|
|
||||||
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if phone := normalizePhoneTag(env.Phone); phone != "" {
|
|
||||||
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s SET TAG phone = '%s'", w.qualify(table), quote(phone))); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
if phone := normalizePhoneTag(env.Phone); phone != "" {
|
||||||
w.cache.mu.Lock()
|
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s SET TAG phone = '%s'", w.qualify(table), quote(phone))); err != nil {
|
||||||
w.cache.seen[key] = struct{}{}
|
return err
|
||||||
w.cache.mu.Unlock()
|
}
|
||||||
return nil
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Writer) ensureLocationChild(ctx context.Context, table string, env envelope.FrameEnvelope) error {
|
func (w *Writer) ensureLocationChild(ctx context.Context, table string, env envelope.FrameEnvelope) error {
|
||||||
key := "vehicle_locations." + table
|
key := "vehicle_locations." + table
|
||||||
w.cache.mu.Lock()
|
return w.cache.doOnce(key, func() error {
|
||||||
_, ok := w.cache.seen[key]
|
statement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s TAGS ('%s', '%s')",
|
||||||
w.cache.mu.Unlock()
|
w.qualify(table),
|
||||||
if ok {
|
w.qualify("vehicle_locations"),
|
||||||
|
quote(string(env.Protocol)),
|
||||||
|
quote(strings.TrimSpace(env.VIN)))
|
||||||
|
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
})
|
||||||
statement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s TAGS ('%s', '%s')",
|
|
||||||
w.qualify(table),
|
|
||||||
w.qualify("vehicle_locations"),
|
|
||||||
quote(string(env.Protocol)),
|
|
||||||
quote(strings.TrimSpace(env.VIN)))
|
|
||||||
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
w.cache.mu.Lock()
|
|
||||||
w.cache.seen[key] = struct{}{}
|
|
||||||
w.cache.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawValues(env envelope.FrameEnvelope, rawHex string, rawText string, parsedFields string) []any {
|
func rawValues(env envelope.FrameEnvelope, rawHex string, rawText string, parsedFields string) []any {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -318,6 +319,47 @@ func TestSchemaStatementsCreateRawFramePayloadChunks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriterEnsuresSameChildTableOnceUnderConcurrency(t *testing.T) {
|
||||||
|
exec := newBlockingExec("CREATE TABLE IF NOT EXISTS raw_")
|
||||||
|
writer := NewWriter(exec)
|
||||||
|
first := sampleEnvelope()
|
||||||
|
second := sampleEnvelope()
|
||||||
|
second.Sequence = 2
|
||||||
|
second.EventID = "second"
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 2)
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
errs <- writer.AppendAll(context.Background(), first)
|
||||||
|
}()
|
||||||
|
<-exec.blocked
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
errs <- writer.AppendAll(context.Background(), second)
|
||||||
|
}()
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
exec.release <- struct{}{}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AppendAll() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := exec.count("CREATE TABLE IF NOT EXISTS raw_"); got != 1 {
|
||||||
|
t.Fatalf("raw child CREATE count = %d, want 1; calls=%#v", got, exec.calls())
|
||||||
|
}
|
||||||
|
if got := exec.count("ALTER TABLE raw_"); got != 1 {
|
||||||
|
t.Fatalf("raw child ALTER count = %d, want 1; calls=%#v", got, exec.calls())
|
||||||
|
}
|
||||||
|
if got := exec.count("INSERT INTO raw_"); got != 2 {
|
||||||
|
t.Fatalf("raw insert count = %d, want 2; calls=%#v", got, exec.calls())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sampleEnvelope() envelope.FrameEnvelope {
|
func sampleEnvelope() envelope.FrameEnvelope {
|
||||||
return envelope.FrameEnvelope{
|
return envelope.FrameEnvelope{
|
||||||
Protocol: envelope.ProtocolJT808,
|
Protocol: envelope.ProtocolJT808,
|
||||||
@@ -392,3 +434,45 @@ func (e *recordingExec) ExecContext(_ context.Context, query string, args ...any
|
|||||||
e.calls = append(e.calls, execCall{query: query, args: args})
|
e.calls = append(e.calls, execCall{query: query, args: args})
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type blockingExec struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
recorded []execCall
|
||||||
|
blockPattern string
|
||||||
|
blocked chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
blockOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBlockingExec(blockPattern string) *blockingExec {
|
||||||
|
return &blockingExec{
|
||||||
|
blockPattern: blockPattern,
|
||||||
|
blocked: make(chan struct{}),
|
||||||
|
release: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *blockingExec) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.recorded = append(e.recorded, execCall{query: query, args: args})
|
||||||
|
e.mu.Unlock()
|
||||||
|
if strings.Contains(query, e.blockPattern) {
|
||||||
|
e.blockOnce.Do(func() {
|
||||||
|
close(e.blocked)
|
||||||
|
<-e.release
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *blockingExec) calls() []execCall {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
out := make([]execCall, len(e.recorded))
|
||||||
|
copy(out, e.recorded)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *blockingExec) count(pattern string) int {
|
||||||
|
return countSQL(e.calls(), pattern)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user