Kaynağa Gözat

并发修复

yl32940 3 hafta önce
ebeveyn
işleme
5a038b4d5c

+ 120 - 25
DB/core/access_lock.go

@@ -9,7 +9,7 @@ import (
 
 // goroutineID 返回当前 goroutine 的 ID。
 //
-// 仅用于实现下面的"操作级可重入锁":同一 goroutine 在一个写事务内多次进入公开
+// 仅用于实现下面的"操作级可重入读写锁":同一 goroutine 在一个写事务内多次进入公开
 // 操作 (BeginTransaction → InsertRow → ... → Commit) 时重入不卡死,跨 goroutine
 // 则互斥排队。Go 官方不鼓励在业务里依赖 goroutine ID,但用它实现可重入互斥是
 // 少数正当用途之一。
@@ -23,41 +23,69 @@ func goroutineID() int64 {
 	return v
 }
 
-// accessLock 操作级可重入串行锁。
+// accessLock 操作级可重入读写锁。
 //
-// 设计定位:引擎是"单写者、操作串行"模型,不支持并发读写。这把锁把多 goroutine
-// 的误并发调用自动串行化 —— 调用方即使不自己加锁,也不会出现:
+// 设计定位:引擎默认是"单写者、操作串行"模型,但允许并发读。这把锁把多 goroutine
+// 的并发调用自动安全化 —— 调用方即使不自己加锁,也不会出现:
 //   - 两个写操作并发,把页 / 事务结构改坏;
-//   - 读者在写操作改到一半时读到"新叶子 + 旧父节点"这类中间态(脏读)。
+//   - 读者在写操作改到一半时读到中间态(脏读);
+//   - 多个查询互相阻塞(读读并行)。
 //
-// 同一 goroutine 重入(典型:显式事务 Begin 持锁后,事务内的 InsertRow / FindByPK
-// 等子操作再次进入)只加深 depth、不阻塞;跨 goroutine 进入则在 cond 上排队,
-// 直到持有者在 Commit / Rollback 释放。
+// 锁语义:
 //
-// 内部 mu 只用于短暂保护 owner/depth 字段与 cond 等待,绝不跨整个临界区持有 —
-// 否则同 goroutine 重入时会在不可重入的 sync.Mutex 上自死锁。
+//	RLock / RUnlock —— 读锁, 可并发, 不阻塞其他 RLock, 被写锁阻塞
+//	Lock  / Unlock  —— 写锁, 排他, 阻塞所有 RLock 和 Lock
+//
+// 可重入规则 (关键):
+//
+//	同 goroutine 已持有写锁 → 再 Lock 加深 depth, 再 RLock 直接跳过
+//	同 goroutine 已持有自己的读锁 → 再 RLock 加深 rDepth[g], 再 Lock 直接跳过 (自安全)
+//
+// 后一条保证 scanWithPKID (RLock) → 回调 DeleteRow (Lock) 这类嵌套不卡死:
+// 同 goroutine 自己持有的读锁不阻塞自己的写锁请求 —— 单 goroutine 不可能并发改坏结构。
+//
+// 跨 goroutine:
+//
+//	写锁等 (owner==0 && readers - totalSelfReaders == 0) —— 只等**别的 goroutine** 的读者
+//	读锁等 (owner==0)
+//
+// 内部 mu 只用于短暂保护 owner/depth/readers 字段与 cond 等待, 绝不跨临界区持有。
 type accessLock struct {
-	mu    sync.Mutex
-	cond  *sync.Cond // 懒初始化; 跨 goroutine 等待 owner 交锁
-	owner int64      // 持锁 goroutine ID(0 = 无人持有)
-	depth int        // 重入深度
+	mu      sync.Mutex
+	cond    *sync.Cond    // 懒初始化
+	owner   int64         // 写锁持有者 goroutine ID (0 = 无人持有)
+	depth   int           // 写锁重入深度
+	readers int32         // 全局读锁总数 (含各 goroutine 自己的)
+	rDepths map[int64]int // 每个 goroutine 自己的读锁深度
 }
 
-// Lock 获取锁:持有者重入仅加深 depth;否则阻塞直到真正拿到。
+// Lock 获取写锁。
+//   - 同 goroutine 已持有写锁 → depth++, 直接返回 (可重入)
+//   - 同 goroutine 已持有自己的读锁 → 直接跳过 (自安全, 避免 RLock→Lock 死锁)
+//   - 否则阻塞, 直到 owner==0 且 readers==0 (没有其他 goroutine 在读)
 func (a *accessLock) Lock() {
 	g := goroutineID()
 	a.mu.Lock()
 	if a.cond == nil {
 		a.cond = sync.NewCond(&a.mu)
+		if a.rDepths == nil {
+			a.rDepths = make(map[int64]int)
+		}
 	}
+	// 同 goroutine 已持有写锁 → 重入
 	if a.owner == g {
 		a.depth++
 		a.mu.Unlock()
 		return
 	}
-	// 跨 goroutine:在 cond 上等待, 直到当前持有者交锁 (owner 归零)。
-	// Wait 会临时释放 mu, 被唤醒时重新持有, 因此用 for 复检以防虚假唤醒 / 被他人抢先。
-	for a.owner != 0 {
+	// 同 goroutine 已持有自己的读锁 → 跳过 (单 goroutine 自安全)
+	if selfDepth, ok := a.rDepths[g]; ok && selfDepth > 0 {
+		a.mu.Unlock()
+		return
+	}
+	// 等待: 别人持有写锁 OR readers > 0
+	// readers 里**已经扣掉了**自己持有的 (因为上面已经 return 如果自己有), 所以直接等
+	for a.owner != 0 || a.readers > 0 {
 		a.cond.Wait()
 	}
 	a.owner = g
@@ -65,7 +93,7 @@ func (a *accessLock) Lock() {
 	a.mu.Unlock()
 }
 
-// Unlock 释放一层:depth 归零才真正交锁并唤醒等待者。
+// Unlock 释放一层写锁。depth 归零才真正交锁并唤醒所有等待者。
 func (a *accessLock) Unlock() {
 	g := goroutineID()
 	a.mu.Lock()
@@ -79,18 +107,85 @@ func (a *accessLock) Unlock() {
 		return
 	}
 	a.owner = 0
+	a.depth = 0
 	if a.cond != nil {
-		a.cond.Broadcast() // 唤醒全部等待者, 它们按 for 复检重新竞争
+		a.cond.Broadcast()
 	}
 	a.mu.Unlock()
 }
 
-// lockOp 是公开操作入口的便捷写法:
-//
-//	defer db.lockOp()()
-//
-// db.lockOp() 在本行立即执行(拿到锁),返回的解锁函数在 defer 出作用域时调用。
+// RLock 获取读锁。
+//   - 同 goroutine 已持有写锁 → 直接返回 (写锁已独占, 读安全)
+//   - 同 goroutine 已有自己的读锁 → rDepths[g]++, readers++ (读可重入)
+//   - 否则等待写锁释放, 然后 readers++, rDepths[g]=1
+func (a *accessLock) RLock() {
+	g := goroutineID()
+	a.mu.Lock()
+	if a.cond == nil {
+		a.cond = sync.NewCond(&a.mu)
+		if a.rDepths == nil {
+			a.rDepths = make(map[int64]int)
+		}
+	}
+	if a.owner == g {
+		// 同 goroutine 持有写锁, 读一定安全, 跳过
+		a.mu.Unlock()
+		return
+	}
+	// 同 goroutine 读锁重入
+	if a.rDepths[g] > 0 {
+		a.rDepths[g]++
+		a.readers++
+		a.mu.Unlock()
+		return
+	}
+	// 等待: 写锁被别人持有
+	for a.owner != 0 {
+		a.cond.Wait()
+	}
+	a.rDepths[g] = 1
+	a.readers++
+	a.mu.Unlock()
+}
+
+// RUnlock 释放当前 goroutine 的一层读锁。
+// 自己的 rDepth 归零才从 map 删除。全局 readers 归零时唤醒等待写锁的 goroutine。
+func (a *accessLock) RUnlock() {
+	g := goroutineID()
+	a.mu.Lock()
+	if a.rDepths == nil || a.rDepths[g] <= 0 {
+		a.mu.Unlock()
+		return
+	}
+	a.readers--
+	a.rDepths[g]--
+	if a.rDepths[g] == 0 {
+		delete(a.rDepths, g)
+	}
+	if a.readers == 0 && a.cond != nil {
+		a.cond.Broadcast()
+	}
+	a.mu.Unlock()
+}
+
+// ============================================================================
+// 便捷入口 (调用点用 defer xxx()())
+// ============================================================================
+
+// lockOp 写锁便捷写法 —— 原有接口不变, 内部用 Lock。
 func (db *DB) lockOp() func() {
 	db.accMu.Lock()
 	return db.accMu.Unlock
 }
+
+// lockRead 读锁便捷写法 —— 纯查询/读路径用这个, 多个 goroutine 可并行。
+func (db *DB) lockRead() func() {
+	db.accMu.RLock()
+	return db.accMu.RUnlock
+}
+
+// lockWrite 显式写锁便捷写法 (和 lockOp 等价, 命名更明确)。
+func (db *DB) lockWrite() func() {
+	db.accMu.Lock()
+	return db.accMu.Unlock
+}

+ 188 - 0
DB/core/access_lock_test.go

@@ -0,0 +1,188 @@
+package core
+
+import (
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+// TestAccessLock_ReadReadParallel 验证多个读者真正并行 (不互相阻塞)。
+// 原理: 10 个 goroutine 同时 RLock, 每个 sleep 200ms, 总耗时应 ~200ms 而非 ~2000ms。
+func TestAccessLock_ReadReadParallel(t *testing.T) {
+	var l accessLock
+	const readers = 10
+	const hold = 200 * time.Millisecond
+
+	start := time.Now()
+	var wg sync.WaitGroup
+	wg.Add(readers)
+	for i := 0; i < readers; i++ {
+		go func() {
+			defer wg.Done()
+			l.RLock()
+			time.Sleep(hold)
+			l.RUnlock()
+		}()
+	}
+	wg.Wait()
+	elapsed := time.Since(start)
+
+	if elapsed >= hold*2 { // 给 2x 余量, 超过说明有串行化
+		t.Errorf("读读应该并行, 但耗时 %v (期望 < %v)", elapsed, hold*2)
+	}
+	t.Logf("10 个读者并行耗时: %v", elapsed)
+}
+
+// TestAccessLock_WriteBlocksRead 验证写锁阻塞读。
+func TestAccessLock_WriteBlocksRead(t *testing.T) {
+	var l accessLock
+	const hold = 200 * time.Millisecond
+	const waitTolerance = hold / 2
+
+	// goroutine A: 拿写锁, sleep 200ms
+	doneA := make(chan struct{})
+	go func() {
+		l.Lock()
+		time.Sleep(hold)
+		l.Unlock()
+		close(doneA)
+	}()
+
+	// 等 goroutine A 真正拿到锁
+	time.Sleep(20 * time.Millisecond)
+
+	// goroutine B: 尝试 RLock, 应该被阻塞, 直到 A 释放
+	startB := time.Now()
+	l.RLock()
+	elapsedB := time.Since(startB)
+	l.RUnlock()
+
+	if elapsedB < hold-waitTolerance {
+		t.Errorf("写锁应该阻塞读, 但 B 只等了 %v (期望 >= %v)", elapsedB, hold-waitTolerance)
+	}
+	t.Logf("读等写释放耗时: %v", elapsedB)
+
+	<-doneA
+}
+
+// TestAccessLock_ReentrantWrite 验证同 goroutine 写锁重入不卡死。
+func TestAccessLock_ReentrantWrite(t *testing.T) {
+	var l accessLock
+	const depth = 10
+
+	// 同 goroutine 连续 Lock 10 次, 再 Unlock 10 次
+	for i := 0; i < depth; i++ {
+		l.Lock()
+	}
+	for i := 0; i < depth; i++ {
+		l.Unlock()
+	}
+	t.Logf("写锁 %d 层重入正常", depth)
+}
+
+// TestAccessLock_ReentrantRLockUnderWrite 验证同 goroutine 持有写锁时再 RLock 不卡死。
+// 这是 TsDB2 特有的关键路径: BeginTransaction (Lock) → InsertRow → scanWithPKID (RLock)
+func TestAccessLock_ReentrantRLockUnderWrite(t *testing.T) {
+	var l accessLock
+
+	l.Lock()
+	l.RLock() // 同 goroutine, 已持写锁, 应该跳过
+	l.RUnlock()
+	l.Unlock()
+	t.Logf("同 goroutine 写锁下 RLock 正常")
+}
+
+// TestAccessLock_WriteWaitsForReaders 验证写锁等待现有读者离开。
+func TestAccessLock_WriteWaitsForReaders(t *testing.T) {
+	var l accessLock
+	const hold = 200 * time.Millisecond
+	const tolerance = hold / 2
+
+	// goroutine A: RLock, sleep 200ms
+	doneA := make(chan struct{})
+	go func() {
+		l.RLock()
+		time.Sleep(hold)
+		l.RUnlock()
+		close(doneA)
+	}()
+
+	// 等 A 拿到 RLock
+	time.Sleep(20 * time.Millisecond)
+
+	// goroutine B: 尝试 Lock, 应该等 A 的 RUnlock
+	startB := time.Now()
+	l.Lock()
+	elapsedB := time.Since(startB)
+	l.Unlock()
+
+	if elapsedB < hold-tolerance {
+		t.Errorf("写锁应该等待读者, 但 B 只等了 %v (期望 >= %v)", elapsedB, hold-tolerance)
+	}
+	t.Logf("写锁等待读者耗时: %v", elapsedB)
+
+	<-doneA
+}
+
+// TestAccessLock_DeadlockStress 长时间压力测试, 确保没有死锁。
+// 10 个读者 goroutine + 2 个写者 goroutine, 混合跑 3 秒。
+func TestAccessLock_DeadlockStress(t *testing.T) {
+	var l accessLock
+	var reads, writes int64
+
+	stop := make(chan struct{})
+	var wg sync.WaitGroup
+
+	// 读者: RLock → 少量工作 → RUnlock
+	for i := 0; i < 10; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for {
+				select {
+				case <-stop:
+					return
+				default:
+					l.RLock()
+					atomic.AddInt64(&reads, 1)
+					l.RUnlock()
+				}
+			}
+		}()
+	}
+
+	// 写者: Lock → 少量工作 → Unlock
+	for i := 0; i < 2; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for {
+				select {
+				case <-stop:
+					return
+				default:
+					l.Lock()
+					atomic.AddInt64(&writes, 1)
+					l.Unlock()
+				}
+			}
+		}()
+	}
+
+	// 跑 3 秒后停止
+	time.Sleep(3 * time.Second)
+	close(stop)
+
+	done := make(chan struct{})
+	go func() { wg.Wait(); close(done) }()
+
+	select {
+	case <-done:
+		r := atomic.LoadInt64(&reads)
+		w := atomic.LoadInt64(&writes)
+		t.Logf("3 秒压力测试通过, reads=%d writes=%d", r, w)
+	case <-time.After(2 * time.Second):
+		t.Fatal("压力测试超时 —— 疑似死锁!")
+	}
+}

+ 33 - 0
DB/core/db.go

@@ -580,6 +580,39 @@ func (db *DB) TruncateTable(name string) error {
 	return db.ddlBoundary("TruncateTable")
 }
 
+// RenameTable 重命名表 (DDL, 隐式提交)。只改 TableInfo.Name 元数据字符串,
+// 不涉及数据页 / 索引页迁移或重建。事务内拒绝。
+func (db *DB) RenameTable(oldName, newName string) error {
+	if err := db.errIfReadOnly(); err != nil {
+		return err
+	}
+	if db.HasActiveTransaction() {
+		return errDDLInTransaction("RenameTable")
+	}
+	defer db.lockOp()()
+	if err := db.tables.RenameTable(oldName, newName); err != nil {
+		return err
+	}
+	return db.ddlBoundary("RenameTable")
+}
+
+// RenameColumn 重命名列 (DDL, 隐式提交)。只改 FieldInfo.FieldName + schema 落盘,
+// 不涉及列偏移 / 行数据 / 索引键重建。事务内拒绝。
+func (db *DB) RenameColumn(tableName, oldCol, newCol string) error {
+	if err := db.errIfReadOnly(); err != nil {
+		return err
+	}
+	if db.HasActiveTransaction() {
+		return errDDLInTransaction("RenameColumn")
+	}
+	defer db.lockOp()()
+	tbl, ok := db.tables.Find(tableName)
+	if !ok {
+		return ErrTableNotFound
+	}
+	return tbl.RenameColumn(oldCol, newCol)
+}
+
 func (db *DB) GetAllTables() []TableMeta {
 	res := make([]TableMeta, 0, len(db.tables.tables))
 	for _, t := range db.tables.tables {

+ 72 - 4
DB/core/index_meta_page.go

@@ -1,15 +1,16 @@
 package core
 
 import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/index"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 	"bytes"
 	"encoding/binary"
 	"fmt"
 	"slices"
 	"unsafe"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/index"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 )
 
 // 🔥 最终对接版:只存索引定义 + 指向你的 IndexPage
@@ -357,6 +358,58 @@ func (im *IndexMetaPage) DropIndex(idxName string) error {
 	return ErrIndexNotFound
 }
 
+// RenameIndex 重命名索引 (只改 IndexMeta.IndexName 字符串, 零索引页迁移)。
+func (im *IndexMetaPage) RenameIndex(oldName, newName string) error {
+	if len(newName) > IndexNameSize {
+		return fmt.Errorf("新索引名 %q 太长: %d > %d", newName, len(newName), IndexNameSize)
+	}
+	// 新名不能和别的活索引重名
+	for _, m := range im.metas {
+		if util.BytesToString(m.IndexName[:]) == newName && m.Flag&0x80 == 0 {
+			return fmt.Errorf("索引 %q 已存在", newName)
+		}
+	}
+
+	// 盘上改
+	cur := im.hdr.PageID
+	for cur != 0 {
+		buf := make([]byte, PageSize)
+		im.handle.ReadPage(cur, buf)
+		hdr := page.HdrOf(buf)
+		if hdr.Type != page.IndexMetaIndex || hdr.PageID != cur {
+			break
+		}
+		offset := im.hdrSize
+		for offset+im.metaSize <= hdr.UseSize {
+			m := (*IndexMeta)(unsafe.Pointer(&buf[offset]))
+			if util.BytesToString(m.IndexName[:]) == oldName && m.Flag&0x80 == 0 {
+				for i := range m.IndexName {
+					m.IndexName[i] = 0
+				}
+				copy(m.IndexName[:], newName)
+				if err := im.handle.SavePage(cur, buf); err != nil {
+					return err
+				}
+				// 内存态同步
+				for i := range im.metas {
+					if util.BytesToString(im.metas[i].IndexName[:]) == oldName {
+						for j := range im.metas[i].IndexName {
+							im.metas[i].IndexName[j] = 0
+						}
+						copy(im.metas[i].IndexName[:], newName)
+						break
+					}
+				}
+				im.invalidateIndexCache()
+				return nil
+			}
+			offset += im.metaSize
+		}
+		cur = hdr.NextPage
+	}
+	return ErrIndexNotFound
+}
+
 func (im *IndexMetaPage) InitHeader(pageID uint32) ([]byte, *page.PageHdr) {
 	buf := make([]byte, PageSize)
 	hdr := page.HdrOf(buf)
@@ -428,6 +481,21 @@ func (m *IndexMeta) SetUnique(v bool) {
 	}
 }
 
+// SetName 就地设置索引名 (仅改内存元信息, 落盘由 saveMeta 负责)。
+func (m *IndexMeta) SetName(name string) error {
+	if m == nil {
+		return fmt.Errorf("nil IndexMeta")
+	}
+	if len(name) > IndexNameSize {
+		return fmt.Errorf("索引名 %q 太长: %d > %d", name, len(name), IndexNameSize)
+	}
+	for i := range m.IndexName {
+		m.IndexName[i] = 0
+	}
+	copy(m.IndexName[:], name)
+	return nil
+}
+
 // GetIndexes 返回本表全部可用索引的元数据拷贝 (按创建顺序, 已删除的跳过)。
 //
 // 元数据是迁移/导出类工具的入口: 在此之前 core 包外没有任何办法枚举一张表的索引

+ 1 - 1
DB/core/query.go

@@ -126,7 +126,7 @@ func (q *Query) ScanWithPKID(callback func(pkID uint64, row []types.FieldValue)
 // "取第一个 uint64 类型的字段值当 pkID" —— 主键列不是行内第一个 8 字节列时,
 // 它删掉的是另一个主键指向的行, 匹配上的行反而一条都没动, 而且不报错。
 func (q *Query) scanWithPKID(callback func(pkID uint64, row []types.FieldValue) bool) error {
-	defer q.table.db.lockOp()() // 查询全程串行; 被 Deleter.Exec 调用时同 goroutine 重入
+	defer q.table.db.lockRead()() // 读锁: 纯查询可并发; 被 Deleter.Exec 调用时 (外层已持写锁) 同 goroutine 跳过
 	plan := q.buildScanPlan()
 
 	// 判断是否需要内存排序 (OrderBy 字段与索引不一致时)

+ 17 - 0
DB/core/table_index.go

@@ -184,6 +184,23 @@ func (table *Table) DropIndex(idx string) error {
 	return nil
 }
 
+// RenameIndex 重命名索引 (DDL, 隐式提交)。只改 IndexMeta.IndexName 字符串, 零索引页迁移。
+func (table *Table) RenameIndex(oldName, newName string) error {
+	if table.db != nil && table.db.HasActiveTransaction() {
+		return errDDLInTransaction("RenameIndex")
+	}
+	defer table.db.lockOp()()
+	if err := table.indexMeta.RenameIndex(oldName, newName); err != nil {
+		return err
+	}
+	if table.db != nil {
+		if err := table.db.ddlBoundary("RenameIndex"); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // BuildIndexKey 按索引定义, 把各分量值编码成索引键 (与写入侧 extractIndexKey 同一编码)。
 //
 // 复合索引的键是各分量定长拼接, 外部无法凭空构造 —— 因此点查/删除/更新复合索引

+ 58 - 3
DB/core/table_page.go

@@ -1,14 +1,15 @@
 package core
 
 import (
+	"encoding/binary"
+	"fmt"
+	"unsafe"
+
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index/btree"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
-	"encoding/binary"
-	"fmt"
-	"unsafe"
 )
 
 type TablePage struct {
@@ -620,6 +621,60 @@ func (t *TablePage) findTableByName(name string) *Table {
 	return nil
 }
 
+// ===========================================================================
+// ✅ RenameTable 重命名表 (只改元数据字符串, 不涉及数据页/索引页迁移)
+// ===========================================================================
+func (t *TablePage) RenameTable(oldName, newName string) error {
+	if len(newName) > TableNameSize {
+		return fmt.Errorf("%w: 新表名 %q 有 %d 字节, 上限 %d",
+			ErrTableNameTooLong, newName, len(newName), TableNameSize)
+	}
+	// 1. 内存里新表名不能已存在
+	for _, tb := range t.tables {
+		if util.BytesToString(tb.info.Name[:]) == newName && !util.IsHighBitSet(tb.info.Flag) {
+			return ErrTableExists
+		}
+	}
+	tb := t.findTableByName(oldName)
+	if tb == nil || util.IsHighBitSet(tb.info.Flag) {
+		return ErrTableNotFound
+	}
+
+	// 2. 改内存中的 TableInfo.Name
+	copy(tb.info.Name[:], newName)
+	// 清零剩余字节 (旧名可能更长, 尾巴残留会让 BytesToString 读到垃圾)
+	for i := len(newName); i < TableNameSize; i++ {
+		tb.info.Name[i] = 0
+	}
+
+	// 3. 持久化盘上 TableInfo: 遍历表页链找到这条 TableInfo, 原地改 Name 字段
+	for pageID := t.hdr.PageID; pageID != 0; {
+		buf := make([]byte, PageSize)
+		if err := t.handle.ReadPage(pageID, buf); err != nil {
+			return err
+		}
+		hdr := page.HdrOf(buf)
+		offset := t.hdrSize + schemaExtHeadSize
+		for offset+t.tableInfoSize <= hdr.UseSize {
+			info := (*TableInfo)(unsafe.Pointer(&buf[offset]))
+			if util.BytesToString(info.Name[:]) == oldName && !util.IsHighBitSet(info.Flag) {
+				copy(info.Name[:], newName)
+				for i := len(newName); i < TableNameSize; i++ {
+					info.Name[i] = 0
+				}
+				if err := t.handle.SavePage(pageID, buf); err != nil {
+					return err
+				}
+				return nil
+			}
+			size := t.tableInfoSize + uint32(info.FieldAllocateSize)
+			offset += size
+		}
+		pageID = hdr.NextPage
+	}
+	return fmt.Errorf("rename: 磁盘上找不到表 %q (内存有但盘上无, 可能未 Commit)", oldName)
+}
+
 // ===========================================================================
 // ✅ Drop 删除表并回收所有页
 // ===========================================================================

+ 56 - 0
DB/core/table_schema.go

@@ -6,6 +6,7 @@ import (
 	"unsafe"
 
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 )
 
 // validateFieldsForSchema 校验一组列定义能否构成合法 schema:
@@ -832,3 +833,58 @@ func (table *Table) DropField(fieldName string) error {
 	}
 	return nil
 }
+
+// =============================================================================
+// 重命名字段 (只改 FieldInfo.FieldName 字节 + writeSchema, 不改任何列偏移/索引)
+// =============================================================================
+func (table *Table) RenameColumn(oldName, newName string) error {
+	if table.db != nil && table.db.HasActiveTransaction() {
+		return errDDLInTransaction("RenameColumn")
+	}
+	if len(newName) == 0 || len(newName) > FieldNameSize {
+		return fmt.Errorf("列名长度 %d 非法 (1..%d)", len(newName), FieldNameSize)
+	}
+	defer table.db.lockOp()()
+
+	// 找目标列 (只能是活跃列, 已删列不能改名)
+	var target *types.FieldInfo
+	for i := range table.Field {
+		f := &table.Field[i]
+		if f.IsDropped() {
+			continue
+		}
+		if util.BytesToString(f.FieldName) == oldName {
+			target = f
+			break
+		}
+	}
+	if target == nil {
+		return fmt.Errorf("列 %q 不存在", oldName)
+	}
+
+	// 新名不能和别的活跃列重名
+	for i := range table.Field {
+		f := &table.Field[i]
+		if f.IsDropped() {
+			continue
+		}
+		if f != target && util.BytesToString(f.FieldName) == newName {
+			return fmt.Errorf("列 %q 已存在", newName)
+		}
+	}
+
+	// 改内存态 FieldName (新建切片, 不原地改引用外部可能持有)
+	target.FieldName = make([]byte, len(newName))
+	copy(target.FieldName, newName)
+
+	// schema 落盘 (writeSchema 会重新 serializeSchema 把新名写进去)
+	if err := table.writeSchema(); err != nil {
+		return err
+	}
+	if table.db != nil {
+		if err := table.db.ddlBoundary("RenameColumn"); err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 67 - 0
analyzer/headless_audit_test.go

@@ -0,0 +1,67 @@
+package main
+
+// 无头审计: 复用 analyzer 全套审计逻辑 (结构 + 索引内容一致性), 不走 HTTP。
+// 用法: AUDIT_DB=<库文件> AUDIT_OUT=<JSON 输出> go test -run TestHeadlessAudit -v ./analyzer
+// 若被分析库旁边有 <db>.analyzer 缓存且指纹匹配, 会直接命中缓存秒回。
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"testing"
+	"time"
+)
+
+func TestHeadlessAudit(t *testing.T) {
+	p := os.Getenv("AUDIT_DB")
+	if p == "" {
+		t.Skip("AUDIT_DB 未设置")
+	}
+	out := os.Getenv("AUDIT_OUT")
+	if out == "" {
+		out = "audit_out.json"
+	}
+	if err := useDB(p); err != nil {
+		t.Fatal("打开库:", err)
+	}
+	invalidateAudit()
+	mu.Lock()
+	fp = p
+	rHdr()
+	tabs = pTabs()
+	mu.Unlock()
+	setCacheFP(fingerprint())
+
+	deadline := time.Now().Add(15 * time.Minute)
+	for {
+		a, _, _ := computeAudit()
+		if a == nil {
+			cacheMu.Lock()
+			msg := auditErrMsg
+			cacheMu.Unlock()
+			t.Fatal("审计失败:", msg)
+		}
+		if !a.IdxPending {
+			b, err := json.MarshalIndent(a, "", "  ")
+			if err != nil {
+				t.Fatal("序列化:", err)
+			}
+			if err := os.WriteFile(out, b, 0o644); err != nil {
+				t.Fatal("写结果:", err)
+			}
+			fmt.Printf("审计完成: verdict=%s pages=%d 孤儿=%d 交叉引用=%d 非法指针=%d "+
+				"索引序乱=%d 悬空PK=%d 缺主键条目=%d 缺二级条目=%d 空闲活页=%d\n",
+				a.Verdict, a.TotalPages,
+				a.Reach.Unaccounted,
+				a.MultiRef, a.BadRefCount,
+				a.IdxBadOrder, a.IdxOrphanPK, a.IdxMissingRow, a.IdxMissingEntry,
+				len(a.FreeLive))
+			fmt.Println("结果 JSON:", out)
+			return
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("索引内容补全超时")
+		}
+		time.Sleep(500 * time.Millisecond)
+	}
+}

BIN
archive.7z


BIN
docker.db.sqlite


+ 47 - 6
gormvrtx/migrator.go

@@ -428,9 +428,32 @@ func (m Migrator) HasTable(value interface{}) bool {
 	return ok
 }
 
-// RenameTable 引擎不支持表重命名。
+// RenameTable 调 core.DB.RenameTable (DDL 隐式提交)。
+// oldName/newName 支持 string 或实现 schema.Tabler 的模型。
 func (m Migrator) RenameTable(oldName, newName interface{}) error {
-	return errNotSupported("RenameTable")
+	oldTable := tableNameOf(oldName)
+	if oldTable == "" {
+		return fmt.Errorf("vrtx: RenameTable 无法解析 oldName")
+	}
+	newTable := tableNameOf(newName)
+	if newTable == "" {
+		return fmt.Errorf("vrtx: RenameTable 无法解析 newName")
+	}
+	if err := m.eng.RenameTable(oldTable, newTable); err != nil {
+		return fmt.Errorf("vrtx: 重命名表 %q → %q: %w", oldTable, newTable, err)
+	}
+	return nil
+}
+
+// tableNameOf 把 GORM RenameTable 参数 (string / schema.Tabler) 解析成表名字符串。
+func tableNameOf(v interface{}) string {
+	switch x := v.(type) {
+	case string:
+		return x
+	case schema.Tabler:
+		return x.TableName()
+	}
+	return ""
 }
 
 // GetTables 列出全部表名。
@@ -520,9 +543,16 @@ func (m Migrator) HasColumn(value interface{}, field string) bool {
 	return tableFieldIndex(tbl, field) >= 0
 }
 
-// RenameColumn 引擎不支持列重命名。
+// RenameColumn 调 core.DB.RenameColumn (DDL 隐式提交)。
 func (m Migrator) RenameColumn(value interface{}, oldName, field string) error {
-	return errNotSupported("RenameColumn")
+	stmt, err := m.stmtFor(value)
+	if err != nil {
+		return err
+	}
+	if err := m.eng.RenameColumn(stmt.Table, oldName, field); err != nil {
+		return fmt.Errorf("vrtx: 重命名列 %q.%q → %q: %w", stmt.Table, oldName, field, err)
+	}
+	return nil
 }
 
 // ColumnTypes 从引擎元数据构造列类型列表 (供高级用法)。
@@ -731,9 +761,20 @@ func (m Migrator) HasIndex(value interface{}, name string) bool {
 	return false
 }
 
-// RenameIndex 引擎不支持索引重命名 (删了重建)。
+// RenameIndex 调 core.Table.RenameIndex (DDL 隐式提交)。
 func (m Migrator) RenameIndex(value interface{}, oldName, newName string) error {
-	return errNotSupported("RenameIndex (请删除后重建)")
+	stmt, err := m.stmtFor(value)
+	if err != nil {
+		return err
+	}
+	tbl, err := m.findTable(stmt.Table)
+	if err != nil {
+		return err
+	}
+	if err := tbl.RenameIndex(oldName, newName); err != nil {
+		return fmt.Errorf("vrtx: 重命名索引 %q.%q → %q: %w", stmt.Table, oldName, newName, err)
+	}
+	return nil
 }
 
 // GetIndexes 包装引擎索引元数据, 能返尽返。

+ 99 - 0
migrate/live_copy_heal_test.go

@@ -0,0 +1,99 @@
+package migrate
+
+// 临时实验: 活拷文件自愈验证。用真实引擎打开(含 WAL 恢复) → Commit → Close,
+// 对比自愈前后的孤儿页口径。不会碰原件 (先拷到 t.TempDir)。
+// 用法: GO_LIVECOPY=<docker.db 路径> go test ./migrate -run TestLiveCopyHeal -v
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/internal/orphancheck"
+)
+
+func TestLiveCopyHeal(t *testing.T) {
+	src := os.Getenv("GO_LIVECOPY")
+	if src == "" {
+		t.Skip("GO_LIVECOPY 未设置")
+	}
+	dst := filepath.Join(t.TempDir(), "docker.db")
+	fin, err := os.Open(src)
+	if err != nil {
+		t.Fatal(err)
+	}
+	fout, err := os.Create(dst)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := io.Copy(fout, fin); err != nil {
+		t.Fatal(err)
+	}
+	fin.Close()
+	fout.Close()
+
+	before, err := orphancheck.Run(dst, orphancheck.Options{EngineBlobScan: true, SamplesPerType: 2})
+	if err != nil {
+		t.Fatal("自愈前取证:", err)
+	}
+	fmt.Printf("== 自愈前: analyzer 孤儿=%d (blob 感知=%d, 其中 WAL 残留 %d), 空闲链=%d (声明 %d), WALPending=%v\n",
+		len(before.AnalyzerOrphans), len(before.Orphans), before.AnalyzerWalLeft, before.FreeCount, before.FreeDeclared, before.WALPending)
+
+	db, err := core.OpenWithOptions(dst, core.OpenOptions{})
+	if err != nil {
+		t.Fatal("引擎打开:", err)
+	}
+	fmt.Printf("引擎打开成功 (WAL 恢复已执行)\n")
+	if err := db.Commit(); err != nil {
+		t.Fatal("Commit:", err)
+	}
+	if err := db.Close(); err != nil {
+		t.Fatal("Close:", err)
+	}
+	fmt.Println("引擎 Commit+Close 完成")
+
+	after, err := orphancheck.Run(dst, orphancheck.Options{EngineBlobScan: true, SamplesPerType: 2})
+	if err != nil {
+		t.Fatal("自愈后取证:", err)
+	}
+	fmt.Printf("== 自愈后: analyzer 孤儿=%d (blob 感知=%d, 其中 WAL 残留 %d), 空闲链=%d (声明 %d), WALPending=%v\n",
+		len(after.AnalyzerOrphans), len(after.Orphans), after.AnalyzerWalLeft, after.FreeCount, after.FreeDeclared, after.WALPending)
+
+	// 第二段: VACUUM 重建 — 行数据不动, 索引/空闲链/孤儿页全部重建到新文件。
+	db2, err := core.OpenWithOptions(dst, core.OpenOptions{})
+	if err != nil {
+		t.Fatal("VACUUM 前打开:", err)
+	}
+	vres, err := db2.VACUUM()
+	if err != nil {
+		t.Fatal("VACUUM:", err)
+	}
+	fmt.Printf("VACUUM 完成: %d 字节 → %d 字节, 复制 %d 行\n", vres.OldFileSize, vres.NewFileSize, vres.RowsCopied)
+	if err := db2.Close(); err != nil {
+		t.Fatal("VACUUM 后 Close:", err)
+	}
+
+	final, err := orphancheck.Run(dst, orphancheck.Options{EngineBlobScan: true, SamplesPerType: 2})
+	if err != nil {
+		t.Fatal("VACUUM 后取证:", err)
+	}
+	fmt.Printf("== VACUUM 后: analyzer 孤儿=%d (blob 感知=%d, 其中 WAL 残留 %d), 空闲链=%d (声明 %d), WALPending=%v\n",
+		len(final.AnalyzerOrphans), len(final.Orphans), final.AnalyzerWalLeft, final.FreeCount, final.FreeDeclared, final.WALPending)
+	fmt.Println("最终文件保留在:", dst)
+	if out := os.Getenv("GO_LIVEOUT"); out != "" {
+		fin2, _ := os.Open(dst)
+		fout2, err := os.Create(out)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if _, err := io.Copy(fout2, fin2); err != nil {
+			t.Fatal(err)
+		}
+		fin2.Close()
+		fout2.Close()
+		fmt.Println("已复制到:", out)
+	}
+}