️ perf(import-export): 降低 OceanBase 导出链路内存占用

- 为 optional driver-agent 补齐 streamQuery 分片协议,避免大结果集整批缓冲到内存
- 在 OceanBase 整表导出和查询结果导出前强校验 driver-agent revision,旧版代理直接拦截并提示重装
- 为 driver-agent 增加大查询和流式导出完成后的 GC/FreeOSMemory 回收逻辑
- 补充导出前校验、流式分片消费和 agent 内存回收的定向测试
- 更新 driver-agent revisions 以匹配新的流式导出协议
This commit is contained in:
Syngnat
2026-06-18 11:32:08 +08:00
parent 6bd87fa568
commit c8fe90cbee
8 changed files with 801 additions and 22 deletions

View File

@@ -418,6 +418,7 @@ var (
var optionalDriverSourceBuildTimeout = 8 * time.Minute
var validateOptionalDriverAgentExecutableFunc = db.ValidateOptionalDriverAgentExecutable
var resolveOptionalDriverAgentExecutablePathFunc = db.ResolveOptionalDriverAgentExecutablePath
type driverVersionWarmupState struct {
Running bool

View File

@@ -342,6 +342,23 @@ func tryResolveExportTableTotalRows(dbInst db.Database, config connection.Connec
return resolveExportTotalRowsFromRows(rows)
}
func verifyOptionalDriverAgentReadyForExport(config connection.ConnectionConfig) error {
driverType := normalizeDriverType(config.Type)
if !db.IsOptionalGoDriver(driverType) {
return nil
}
executablePath, err := resolveOptionalDriverAgentExecutablePathFunc("", driverType)
if err != nil {
return err
}
if _, err := verifyInstalledOptionalDriverAgentRevision(driverType, executablePath); err != nil {
displayName := resolveDriverDisplayName(driverDefinition{Type: driverType})
return fmt.Errorf("当前导出依赖最新的 %s driver-agent 流式协议;为避免大结果集回退到高内存缓冲模式,请在驱动管理中重装后重试:%w", displayName, err)
}
return nil
}
var exportFileNameSanitizer = strings.NewReplacer(
"/", "_",
"\\", "_",
@@ -2249,6 +2266,11 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName string, tableName string, options ExportFileOptions) connection.QueryResult {
options = normalizeExportFileOptions("", options)
format := options.Format
if format != "sql" {
if err := verifyOptionalDriverAgentReadyForExport(config); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: fmt.Sprintf("Export %s", tableName),
DefaultFilename: fmt.Sprintf("%s.%s", tableName, format),
@@ -3656,6 +3678,11 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName
}
options = normalizeExportFileOptions("", options)
format := options.Format
if format != "sql" {
if err := verifyOptionalDriverAgentReadyForExport(config); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export Query Result",

View File

@@ -475,6 +475,49 @@ func TestTryResolveExportTableTotalRows_UsesCountQuery(t *testing.T) {
}
}
func TestVerifyOptionalDriverAgentReadyForExport_RejectsStaleAgent(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
originalResolvePath := resolveOptionalDriverAgentExecutablePathFunc
t.Cleanup(func() {
optionalDriverAgentMetadataProbe = originalProbe
resolveOptionalDriverAgentExecutablePathFunc = originalResolvePath
})
resolveOptionalDriverAgentExecutablePathFunc = func(downloadDir string, driverType string) (string, error) {
return "/tmp/oceanbase-driver-agent", nil
}
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
return db.OptionalDriverAgentMetadata{
DriverType: driverType,
AgentRevision: "src-stale-agent",
}, nil
}
err := verifyOptionalDriverAgentReadyForExport(connection.ConnectionConfig{Type: "oceanbase"})
if err == nil {
t.Fatal("预期旧版 OceanBase driver-agent 被导出前校验拦截")
}
if !strings.Contains(err.Error(), "流式协议") {
t.Fatalf("错误信息应说明需要流式协议got=%q", err.Error())
}
}
func TestVerifyOptionalDriverAgentReadyForExport_SkipsBuiltInDriver(t *testing.T) {
originalResolvePath := resolveOptionalDriverAgentExecutablePathFunc
t.Cleanup(func() {
resolveOptionalDriverAgentExecutablePathFunc = originalResolvePath
})
resolveOptionalDriverAgentExecutablePathFunc = func(downloadDir string, driverType string) (string, error) {
t.Fatalf("内置驱动导出不应探测 optional driver-agent 路径")
return "", nil
}
if err := verifyOptionalDriverAgentReadyForExport(connection.ConnectionConfig{Type: "mysql"}); err != nil {
t.Fatalf("内置驱动导出不应被 optional driver-agent 校验阻断: %v", err)
}
}
func TestExportQueryResultToFile_UsesStreamQueryPath(t *testing.T) {
f, err := os.CreateTemp("", "gonavi-export-stream-*.csv")
if err != nil {

View File

@@ -4,25 +4,25 @@ package db
func init() {
optionalDriverAgentRevisions = map[string]string{
"mariadb": "src-0a4176f4b5743323",
"oceanbase": "src-7cb0f2c4dc0510a5",
"diros": "src-cc11b882e28fa5d4",
"starrocks": "src-83a6d81c91c7f5c8",
"sphinx": "src-a70c2cd4d223dac2",
"sqlserver": "src-6d5cf334034bce41",
"sqlite": "src-762863d48f653b89",
"duckdb": "src-df5d60ebb175bbbc",
"dameng": "src-596bebeaa016fc74",
"kingbase": "src-2e5a1337b0405c57",
"highgo": "src-5a29a1d3685eb6b4",
"vastbase": "src-e3cfef65512feb23",
"opengauss": "src-58227ba3bc1ec894",
"gaussdb": "src-1458564993a9d455",
"iris": "src-1b072c57af08bec4",
"mongodb": "src-57fdd8bfebdcd46e",
"tdengine": "src-939715f94df1ec9c",
"iotdb": "src-473c39891f926db2",
"clickhouse": "src-482d62ed565b3e69",
"elasticsearch": "src-2fb00b94d7067c56",
"mariadb": "src-b23e2ce1581a5064",
"oceanbase": "src-5067dbdf0ca7b9c4",
"diros": "src-db43faca6bf15d9b",
"starrocks": "src-01e9f06c0fab09d5",
"sphinx": "src-38ee5cae952cc809",
"sqlserver": "src-7a87f6deb816f110",
"sqlite": "src-d3d439cd788880e2",
"duckdb": "src-b11506b8706bfb73",
"dameng": "src-1638124bfd7fce09",
"kingbase": "src-fb3a404cf4eb1bd9",
"highgo": "src-72fe51afa884f6bc",
"vastbase": "src-3d48607603bfd8b7",
"opengauss": "src-709acf442f016e30",
"gaussdb": "src-f6beccc924d71031",
"iris": "src-9ebf5b970a73b341",
"mongodb": "src-367d11cd04e982c1",
"tdengine": "src-3c13c42f18ba01e1",
"iotdb": "src-5ba9da13c6a272f9",
"clickhouse": "src-99c8babfefdf142c",
"elasticsearch": "src-36b2e2b5f49db9d1",
}
}

View File

@@ -2,6 +2,7 @@ package db
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
@@ -28,6 +29,7 @@ const (
optionalAgentMethodOpenSession = "openSession"
optionalAgentMethodCloseSession = "closeSession"
optionalAgentMethodQuery = "query"
optionalAgentMethodStreamQuery = "streamQuery"
optionalAgentMethodExec = "exec"
optionalAgentMethodGetDatabases = "getDatabases"
optionalAgentMethodGetTables = "getTables"
@@ -42,6 +44,12 @@ const (
optionalAgentMetadataProbeTimeout = 5 * time.Second
)
const (
optionalAgentChunkColumns = "columns"
optionalAgentChunkRows = "rows"
optionalAgentChunkDone = "done"
)
type optionalAgentRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
@@ -60,6 +68,7 @@ type optionalAgentResponse struct {
Error string `json:"error,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Fields []string `json:"fields,omitempty"`
ChunkType string `json:"chunkType,omitempty"`
RowsAffected int64 `json:"rowsAffected,omitempty"`
}
@@ -269,6 +278,114 @@ func (c *optionalDriverAgentClient) callWithTimeout(req optionalAgentRequest, ou
}
}
func (c *optionalDriverAgentClient) callStreamQuery(req optionalAgentRequest, consumer QueryStreamConsumer) error {
if consumer == nil {
return fmt.Errorf("query stream consumer required")
}
c.mu.Lock()
defer c.mu.Unlock()
c.nextID++
req.ID = c.nextID
payload, err := json.Marshal(req)
if err != nil {
return err
}
payload = append(payload, '\n')
if _, err := c.stdin.Write(payload); err != nil {
stderrText := c.stderrText()
if stderrText == "" {
return fmt.Errorf("调用 %s 驱动代理失败:%w", driverDisplayName(c.driver), err)
}
return fmt.Errorf("调用 %s 驱动代理失败:%wstderr: %s", driverDisplayName(c.driver), err, stderrText)
}
var columns []string
valueConsumer, useValueConsumer := consumer.(QueryStreamValueConsumer)
for {
line, err := c.reader.ReadBytes('\n')
if err != nil {
stderrText := c.stderrText()
if stderrText == "" {
return fmt.Errorf("读取 %s 驱动代理响应失败:%w", driverDisplayName(c.driver), err)
}
return fmt.Errorf("读取 %s 驱动代理响应失败:%wstderr: %s", driverDisplayName(c.driver), err, stderrText)
}
var resp optionalAgentResponse
if err := json.Unmarshal(line, &resp); err != nil {
return fmt.Errorf("解析 %s 驱动代理响应失败:%w", driverDisplayName(c.driver), err)
}
if !resp.Success {
errText := strings.TrimSpace(resp.Error)
if errText == "" {
errText = fmt.Sprintf("%s 驱动代理返回失败", driverDisplayName(c.driver))
}
return errors.New(errText)
}
switch resp.ChunkType {
case optionalAgentChunkColumns:
columns = append(columns[:0], resp.Fields...)
if err := consumer.SetColumns(columns); err != nil {
return err
}
case optionalAgentChunkRows:
if len(columns) == 0 {
return fmt.Errorf("%s 驱动代理流式响应缺少列信息", driverDisplayName(c.driver))
}
rows, err := decodeOptionalAgentRowValueBatch(resp.Data)
if err != nil {
return fmt.Errorf("解析 %s 驱动代理流式数据失败:%w", driverDisplayName(c.driver), err)
}
for _, row := range rows {
if useValueConsumer {
if err := valueConsumer.ConsumeRowValues(row); err != nil {
return err
}
continue
}
entry := make(map[string]interface{}, len(columns))
for i, column := range columns {
if i < len(row) {
entry[column] = row[i]
} else {
entry[column] = nil
}
}
if err := consumer.ConsumeRow(entry); err != nil {
return err
}
}
case optionalAgentChunkDone:
return nil
default:
return fmt.Errorf("%s 驱动代理返回未知流式分片类型:%s", driverDisplayName(c.driver), strings.TrimSpace(resp.ChunkType))
}
}
}
func decodeOptionalAgentRowValueBatch(data []byte) ([][]interface{}, error) {
if len(data) == 0 {
return nil, nil
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var rows [][]interface{}
if err := decoder.Decode(&rows); err != nil {
return nil, err
}
for rowIdx := range rows {
for colIdx := range rows[rowIdx] {
rows[rowIdx][colIdx] = normalizeQueryValue(rows[rowIdx][colIdx])
}
}
return rows, nil
}
func (c *optionalDriverAgentClient) forceTerminate() {
if c.stdin != nil {
_ = c.stdin.Close()
@@ -397,6 +514,42 @@ func (d *OptionalDriverAgentDB) Query(query string) ([]map[string]interface{}, [
return data, fields, nil
}
func (d *OptionalDriverAgentDB) StreamQuery(query string, consumer QueryStreamConsumer) error {
return d.StreamQueryContext(context.Background(), query, consumer)
}
func (d *OptionalDriverAgentDB) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error {
if err := ctx.Err(); err != nil {
return err
}
client, err := d.requireClient()
if err != nil {
return err
}
err = client.callStreamQuery(optionalAgentRequest{
Method: optionalAgentMethodStreamQuery,
Query: query,
TimeoutMs: timeoutMsFromContext(ctx),
}, consumer)
if isOptionalAgentStreamUnsupportedError(err) {
logger.Warnf("%s 驱动代理暂不支持流式查询回退到缓冲模式err=%v", driverDisplayName(d.driverType), err)
data, columns, queryErr := d.QueryContext(ctx, query)
if queryErr != nil {
return queryErr
}
if err := consumer.SetColumns(columns); err != nil {
return err
}
for _, row := range data {
if err := consumer.ConsumeRow(row); err != nil {
return err
}
}
return nil
}
return err
}
func (d *OptionalDriverAgentDB) ExecContext(ctx context.Context, query string) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
@@ -458,6 +611,39 @@ func (s *optionalDriverAgentSession) Query(query string) ([]map[string]interface
return s.QueryContext(context.Background(), query)
}
func (s *optionalDriverAgentSession) StreamQuery(query string, consumer QueryStreamConsumer) error {
return s.StreamQueryContext(context.Background(), query, consumer)
}
func (s *optionalDriverAgentSession) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error {
if err := s.ensureOpen(); err != nil {
return err
}
err := s.client.callStreamQuery(optionalAgentRequest{
Method: optionalAgentMethodStreamQuery,
SessionID: s.sessionID,
Query: query,
TimeoutMs: timeoutMsFromContext(ctx),
}, consumer)
if isOptionalAgentStreamUnsupportedError(err) {
logger.Warnf("%s 驱动代理事务会话暂不支持流式查询回退到缓冲模式err=%v", driverDisplayName(s.driver), err)
data, columns, queryErr := s.QueryContext(ctx, query)
if queryErr != nil {
return queryErr
}
if err := consumer.SetColumns(columns); err != nil {
return err
}
for _, row := range data {
if err := consumer.ConsumeRow(row); err != nil {
return err
}
}
return nil
}
return err
}
func (s *optionalDriverAgentSession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if err := s.ensureOpen(); err != nil {
return nil, nil, err
@@ -525,6 +711,17 @@ func (s *optionalDriverAgentSession) ensureOpen() error {
return nil
}
func isOptionalAgentStreamUnsupportedError(err error) bool {
if err == nil {
return false
}
text := strings.TrimSpace(err.Error())
if text == "" {
return false
}
return strings.Contains(text, "不支持的方法") || strings.Contains(text, "不支持流式查询")
}
func (d *OptionalDriverAgentDB) GetDatabases() ([]string, error) {
client, err := d.requireClient()
if err != nil {

View File

@@ -1,6 +1,9 @@
package db
import (
"bufio"
"bytes"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -65,3 +68,71 @@ func TestNormalizeKingbaseAgentChangeSetByColumns(t *testing.T) {
t.Fatalf("unexpected update value key \"event name\" after normalization")
}
}
type optionalAgentTestWriteCloser struct {
bytes.Buffer
}
func (w *optionalAgentTestWriteCloser) Close() error { return nil }
type optionalAgentTestStreamConsumer struct {
columns []string
rows [][]interface{}
}
func (c *optionalAgentTestStreamConsumer) SetColumns(columns []string) error {
c.columns = append([]string(nil), columns...)
return nil
}
func (c *optionalAgentTestStreamConsumer) ConsumeRow(row map[string]interface{}) error {
values := make([]interface{}, len(c.columns))
for idx, column := range c.columns {
values[idx] = row[column]
}
c.rows = append(c.rows, values)
return nil
}
func (c *optionalAgentTestStreamConsumer) ConsumeRowValues(values []interface{}) error {
c.rows = append(c.rows, append([]interface{}(nil), values...))
return nil
}
func TestOptionalDriverAgentClientCallStreamQueryConsumesChunks(t *testing.T) {
var stdin optionalAgentTestWriteCloser
stdout := strings.Join([]string{
`{"id":1,"success":true,"chunkType":"columns","fields":["id","name"]}`,
`{"id":1,"success":true,"chunkType":"rows","data":[[1,"alice"],[2,"bob"]]}`,
`{"id":1,"success":true,"chunkType":"done"}`,
}, "\n") + "\n"
client := &optionalDriverAgentClient{
stdin: &stdin,
reader: bufio.NewReader(strings.NewReader(stdout)),
driver: "oceanbase",
}
consumer := &optionalAgentTestStreamConsumer{}
if err := client.callStreamQuery(optionalAgentRequest{
Method: optionalAgentMethodStreamQuery,
Query: "SELECT 1",
}, consumer); err != nil {
t.Fatalf("callStreamQuery 返回错误: %v", err)
}
if len(consumer.columns) != 2 || consumer.columns[0] != "id" || consumer.columns[1] != "name" {
t.Fatalf("流式列定义异常: %#v", consumer.columns)
}
if len(consumer.rows) != 2 {
t.Fatalf("流式行数异常: %#v", consumer.rows)
}
if got := consumer.rows[0][1]; got != "alice" {
t.Fatalf("第 1 行数据异常want=%q got=%v", "alice", got)
}
if got := consumer.rows[1][0]; got != int64(2) {
t.Fatalf("第 2 行 ID 异常want=%d got=%v (%T)", 2, got, got)
}
if !strings.Contains(stdin.String(), `"method":"streamQuery"`) {
t.Fatalf("请求未使用 streamQuery 方法: %s", stdin.String())
}
}