3x-ui/docs/API-DB-MariaDB.md
Sora39831 b4047cee54 feat: allow same email across multiple inbounds and auto-add clients on registration
Remove global unique constraint on client_traffics.email, change email
duplication check to per-inbound scope, and automatically register new
users as disabled clients in all existing inbounds within a transaction.
2026-04-03 01:38:31 +08:00

31 KiB
Raw Blame History

3x-ui MariaDB 迁移 API 文档

本文档说明如何将 3x-ui 面板的数据库从 SQLite 迁移到 MariaDB涵盖所有数据库相关的 API 接口、数据模型映射、SQL 差异及兼容性改造方案。


目录


1. 架构概览与迁移总览

1.1 当前架构SQLite

┌─────────────┐     ┌──────────┐     ┌───────────────┐
│  Web Panel  │────▶│  GORM    │────▶│   SQLite DB   │
│  (Gin)      │     │ v1.31.1  │     │  x-ui.db      │
└─────────────┘     └──────────┘     └───────────────┘
                         │
                    sqlite driver
                  (mattn/go-sqlite3)

1.2 目标架构MariaDB

┌─────────────┐     ┌──────────┐     ┌───────────────┐
│  Web Panel  │────▶│  GORM    │────▶│   MariaDB     │
│  (Gin)      │     │ v1.31.1  │     │  Server       │
└─────────────┘     └──────────┘     └───────────────┘
                         │
                    mysql driver
               (go-sqlite3 → go-mysql-driver)

1.3 迁移范围

组件 SQLite 特有 MariaDB 兼容 需改造
database/db.go 连接初始化 sqlite.Open() mysql.Open()
GORM 标准 CRUD db.Where/First/Create/Save/Delete 相同
gorm.Expr 原子递增 gorm.Expr("up + ?", val) 相同
JSON_EACH() 原始 SQL SQLite 特有 无等价函数
PRAGMA wal_checkpoint SQLite 特有 FLUSH TABLES
PRAGMA integrity_check SQLite 特有 CHECK TABLE
VACUUM SQLite 特有 OPTIMIZE TABLE
IsSQLiteDB() 检查 SQLite 文件头 不适用
JSON_EXTRACT() / JSON_TYPE() SQLite JSON1 MariaDB 原生支持
IFNULL() SQLite MariaDB IFNULL() (也支持 COALESCE)
GROUP_CONCAT() SQLite MariaDB 支持
LIKE 模糊查询 SQLite MariaDB 支持
AutoMigrate GORM 自动生成 GORM 自动生成

2. 环境准备与依赖

2.1 Go 依赖替换

go.mod 中:

移除:

gorm.io/driver/sqlite v1.6.0
github.com/mattn/go-sqlite3 v1.14.38

新增:

gorm.io/driver/mysql v1.5.7

2.2 环境变量

环境变量 说明 示例
XUI_DB_DRIVER 数据库驱动类型 mysql(默认 sqlite
XUI_DB_HOST MariaDB 主机地址 127.0.0.1
XUI_DB_PORT MariaDB 端口 3306
XUI_DB_NAME 数据库名 xui
XUI_DB_USER 数据库用户 xui
XUI_DB_PASSWORD 数据库密码 secret
XUI_DB_CHARSET 字符集 utf8mb4
XUI_DB_FOLDER SQLite 数据库文件夹(保留向后兼容) /etc/x-ui

2.3 MariaDB 数据库初始化

CREATE DATABASE xui CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'xui'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON xui.* TO 'xui'@'localhost';
FLUSH PRIVILEGES;

3. 数据库连接层改造

原始文件:database/db.go

3.1 连接初始化InitDB

当前 SQLite 实现(第 141 行):

db, err = gorm.Open(sqlite.Open(dbPath), c)

MariaDB 改造:

import (
    "gorm.io/driver/mysql"
)

func InitDB(dbDriver, dsn string) error {
    var dialector gorm.Dialector

    switch dbDriver {
    case "mysql":
        dialector = mysql.Open(dsn)
    default:
        // 保持 SQLite 向后兼容
        dialector = sqlite.Open(dsn)
    }

    db, err = gorm.Open(dialector, c)
    // ... 后续逻辑不变
}

3.2 DSN 连接字符串格式

xui:password@tcp(127.0.0.1:3306)/xui?charset=utf8mb4&parseTime=True&loc=Local

3.3 不再需要的功能

函数 原用途 MariaDB 处理
IsSQLiteDB() 验证 SQLite 文件头 MariaDB 下返回 false 或跳过
ValidateSQLiteDB() PRAGMA integrity_check 改用 CHECK TABLEmysqladmin check
Checkpoint() PRAGMA wal_checkpoint 改用 FLUSH TABLES

4. 数据模型与 MariaDB 表结构映射

原始文件:database/model/model.go, xray/client_traffic.go

GORM 的 AutoMigrate 在 MariaDB 下会自动创建表。以下是每个模型到 MariaDB 表结构的映射。

4.1 users

Go 结构体:

type User struct {
    Id       int    `gorm:"primaryKey;autoIncrement"`
    Username string
    Password string
}

MariaDB DDL

CREATE TABLE users (
    id       INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL DEFAULT '',
    password VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4.2 inbounds

Go 结构体:

type Inbound struct {
    Id                   int    `gorm:"primaryKey;autoIncrement"`
    UserId               int
    Up                   int64
    Down                 int64
    Total                int64
    AllTime              int64  `gorm:"default:0"`
    Remark               string
    Enable               bool   `gorm:"index:idx_enable_traffic_reset,priority:1"`
    ExpiryTime           int64
    TrafficReset         string `gorm:"default:never;index:idx_enable_traffic_reset,priority:2"`
    LastTrafficResetTime int64  `gorm:"default:0"`
    Listen               string
    Port                 int
    Protocol             Protocol
    Settings             string  -- LONGTEXT (JSON)
    StreamSettings       string  -- LONGTEXT (JSON)
    Tag                  string  `gorm:"unique"`
    Sniffing             string  -- LONGTEXT (JSON)
}

MariaDB DDL

CREATE TABLE inbounds (
    id                     INT AUTO_INCREMENT PRIMARY KEY,
    user_id                INT NOT NULL DEFAULT 0,
    up                     BIGINT NOT NULL DEFAULT 0,
    down                   BIGINT NOT NULL DEFAULT 0,
    total                  BIGINT NOT NULL DEFAULT 0,
    all_time               BIGINT NOT NULL DEFAULT 0,
    remark                 VARCHAR(255) NOT NULL DEFAULT '',
    enable                 TINYINT(1) NOT NULL DEFAULT 0,
    expiry_time            BIGINT NOT NULL DEFAULT 0,
    traffic_reset          VARCHAR(255) NOT NULL DEFAULT 'never',
    last_traffic_reset_time BIGINT NOT NULL DEFAULT 0,
    listen                 VARCHAR(255) NOT NULL DEFAULT '',
    port                   INT NOT NULL DEFAULT 0,
    protocol               VARCHAR(50) NOT NULL DEFAULT '',
    settings               LONGTEXT,
    stream_settings        LONGTEXT,
    tag                    VARCHAR(255) NOT NULL DEFAULT '',
    sniffing               LONGTEXT,
    UNIQUE KEY idx_tag (tag),
    INDEX idx_enable_traffic_reset (enable, traffic_reset)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

MariaDB 关键差异:

  • boolTINYINT(1) (GORM 自动处理)
  • JSON 字段存储为 LONGTEXTMariaDB 10.2+ 也可用原生 JSON 类型做校验)
  • unique 索引在 MariaDB 中正常工作

4.3 client_traffics

Go 结构体:

type ClientTraffic struct {
    Id         int    `gorm:"primaryKey;autoIncrement"`
    InboundId  int
    Enable     bool
    Email      string `gorm:"unique"`
    Up         int64
    Down       int64
    AllTime    int64
    ExpiryTime int64
    Total      int64
    Reset      int    `gorm:"default:0"`
    LastOnline int64  `gorm:"default:0"`
}

MariaDB DDL

CREATE TABLE client_traffics (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    inbound_id  INT NOT NULL DEFAULT 0,
    enable      TINYINT(1) NOT NULL DEFAULT 0,
    email       VARCHAR(255) NOT NULL DEFAULT '',
    up          BIGINT NOT NULL DEFAULT 0,
    down        BIGINT NOT NULL DEFAULT 0,
    all_time    BIGINT NOT NULL DEFAULT 0,
    expiry_time BIGINT NOT NULL DEFAULT 0,
    total       BIGINT NOT NULL DEFAULT 0,
    reset       INT NOT NULL DEFAULT 0,
    last_online BIGINT NOT NULL DEFAULT 0,
    UNIQUE KEY idx_email (email),
    INDEX idx_inbound_id (inbound_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意: UUIDSubId 字段标记了 gorm:"-",不会持久化到数据库。

4.4 outbound_traffics

MariaDB DDL

CREATE TABLE outbound_traffics (
    id    INT AUTO_INCREMENT PRIMARY KEY,
    tag   VARCHAR(255) NOT NULL DEFAULT '',
    up    BIGINT NOT NULL DEFAULT 0,
    down  BIGINT NOT NULL DEFAULT 0,
    total BIGINT NOT NULL DEFAULT 0,
    UNIQUE KEY idx_tag (tag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4.5 settings

MariaDB DDL

CREATE TABLE settings (
    id    INT AUTO_INCREMENT PRIMARY KEY,
    `key` VARCHAR(255) NOT NULL DEFAULT '',
    value LONGTEXT,
    INDEX idx_key (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意: key 是 MariaDB 保留字,需要反引号包裹。

4.6 inbound_client_ips

MariaDB DDL

CREATE TABLE inbound_client_ips (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    client_email VARCHAR(255) NOT NULL DEFAULT '',
    ips          LONGTEXT,
    UNIQUE KEY idx_client_email (client_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4.7 history_of_seeders

MariaDB DDL

CREATE TABLE history_of_seeders (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    seeder_name  VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5. SQLite 特有 SQL 的 MariaDB 替代方案

5.1 JSON_EACH() — 最大阻塞点

JSON_EACH() 是 SQLite 特有的表值函数,用于展开 JSON 数组。MariaDB 没有等价函数。

以下代码中使用了 JSON_EACH,必须改造:

5.1.1 web/service/inbound.go:144-156getAllEmails()

当前 SQLite SQL

SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
    JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client

MariaDB 替代方案(应用层解析):

func (s *InboundService) getAllEmails() ([]string, error) {
    db := database.GetDB()
    var inbounds []model.Inbound
    err := db.Model(model.Inbound{}).Select("settings").Find(&inbounds).Error
    if err != nil {
        return nil, err
    }
    var emails []string
    for _, inbound := range inbounds {
        clients, _ := s.GetClients(&inbound)
        for _, c := range clients {
            if c.Email != "" {
                emails = append(emails, c.Email)
            }
        }
    }
    return emails, nil
}

5.1.2 web/service/inbound.go:1313-1323MigrationRemoveOrphanedTraffics()

当前 SQLite SQL

DELETE FROM client_traffics
WHERE email NOT IN (
    SELECT JSON_EXTRACT(client.value, '$.email')
    FROM inbounds,
        JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
)

MariaDB 替代方案(应用层):

func (s *InboundService) MigrationRemoveOrphanedTraffics() {
    db := database.GetDB()
    var allEmails []string
    var inbounds []model.Inbound
    db.Model(model.Inbound{}).Select("settings").Find(&inbounds)
    for _, inbound := range inbounds {
        clients, _ := s.GetClients(&inbound)
        for _, c := range clients {
            if c.Email != "" {
                allEmails = append(allEmails, c.Email)
            }
        }
    }
    if len(allEmails) > 0 {
        db.Where("email NOT IN ?", allEmails).Delete(xray.ClientTraffic{})
    } else {
        db.Where("1 = 1").Delete(xray.ClientTraffic{})
    }
}

5.1.3 web/service/inbound.go:2057-2082GetClientTrafficByID()

当前 SQLite SQL

SELECT ... FROM client_traffics WHERE email IN (
    SELECT JSON_EXTRACT(client.value, '$.email') as email
    FROM inbounds,
        JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
    WHERE JSON_EXTRACT(client.value, '$.id') IN (?)
)

MariaDB 替代方案:

func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
    db := database.GetDB()
    // 先从 inbounds 的 settings JSON 中按应用层查出 email
    var inbounds []model.Inbound
    db.Model(model.Inbound{}).
        Where("JSON_EXTRACT(settings, '$.clients[*].id') LIKE ?", "%"+id+"%").
        Find(&inbounds)

    var emails []string
    for _, inbound := range inbounds {
        clients, _ := s.GetClients(&inbound)
        for _, c := range clients {
            if c.ID == id && c.Email != "" {
                emails = append(emails, c.Email)
            }
        }
    }
    if len(emails) == 0 {
        return nil, nil
    }
    var traffics []xray.ClientTraffic
    err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error
    // ... 后续 reconcile 逻辑不变
    return traffics, err
}

5.1.4 sub/subService.go:115-130getInboundsBySubId()

当前 SQLite SQL

SELECT DISTINCT inbounds.id
FROM inbounds,
    JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE protocol in ('vmess','vless','trojan','shadowsocks')
    AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?

MariaDB 替代方案:

func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
    db := database.GetDB()
    var allInbounds []*model.Inbound
    err := db.Model(model.Inbound{}).Preload("ClientStats").
        Where("enable = ? AND protocol IN (?)", true,
            []string{"vmess", "vless", "trojan", "shadowsocks"}).
        Find(&allInbounds).Error
    if err != nil {
        return nil, err
    }
    // 应用层过滤 subId
    var result []*model.Inbound
    for _, inbound := range allInbounds {
        clients, _ := s.inboundService.GetClients(inbound)
        for _, c := range clients {
            if c.Enable && c.SubID == subId {
                result = append(result, inbound)
                break
            }
        }
    }
    return result, nil
}

5.1.5 sub/subService.go:141-162getFallbackMaster()

当前 SQLite SQL

SELECT * FROM inbounds
WHERE JSON_TYPE(settings, '$.fallbacks') = 'array'
AND EXISTS (
    SELECT * FROM json_each(settings, '$.fallbacks')
    WHERE json_extract(value, '$.dest') = ?
)

MariaDB 替代方案:

func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) {
    db := database.GetDB()
    var inbounds []*model.Inbound
    // 使用 MariaDB 的 JSON_TYPE 和 JSON_SEARCH
    err := db.Model(model.Inbound{}).
        Where("JSON_TYPE(JSON_EXTRACT(settings, '$.fallbacks')) = 'ARRAY'").
        Find(&inbounds).Error
    if err != nil {
        return "", 0, "", err
    }
    // 应用层查找匹配 dest 的 fallback
    for _, inbound := range inbounds {
        var settings map[string]any
        json.Unmarshal([]byte(inbound.Settings), &settings)
        if fallbacks, ok := settings["fallbacks"].([]any); ok {
            for _, fb := range fallbacks {
                if f, ok := fb.(map[string]any); ok {
                    if f["dest"] == dest {
                        // 找到匹配,返回主入站信息
                        var stream, masterStream map[string]any
                        json.Unmarshal([]byte(streamSettings), &stream)
                        json.Unmarshal([]byte(inbound.StreamSettings), &masterStream)
                        stream["security"] = masterStream["security"]
                        stream["tlsSettings"] = masterStream["tlsSettings"]
                        stream["externalProxy"] = masterStream["externalProxy"]
                        modifiedStream, _ := json.MarshalIndent(stream, "", "  ")
                        return inbound.Listen, inbound.Port, string(modifiedStream), nil
                    }
                }
            }
        }
    }
    return "", 0, "", fmt.Errorf("fallback master not found for dest: %s", dest)
}

5.2 PRAGMAVACUUM 替代

Checkpoint()database/db.go:195-202

// SQLite:
db.Exec("PRAGMA wal_checkpoint;")

// MariaDB 替代:
func Checkpoint() error {
    sqlDB, err := db.DB()
    if err != nil {
        return err
    }
    _, err = sqlDB.Exec("FLUSH TABLES")
    return err
}

ValidateSQLiteDB()database/db.go:207-228

// SQLite:
db.Raw("PRAGMA integrity_check;").Scan(&res)

// MariaDB 替代:
func ValidateDB() error {
    var tables []string
    db.Raw("SHOW TABLES").Scan(&tables)
    for _, table := range tables {
        var result string
        db.Raw("CHECK TABLE " + table).Scan(&result)
        // 检查 result 中是否包含 "error"
    }
    return nil
}

VACUUMweb/service/inbound.go:2213

// SQLite:
db.Exec(`VACUUM "main"`)

// MariaDB 替代(逐表 OPTIMIZE:
var tables []string
db.Raw("SHOW TABLES").Scan(&tables)
for _, table := range tables {
    db.Exec("OPTIMIZE TABLE " + table)
}

5.3 兼容函数(可直接使用)

函数 SQLite MariaDB 备注
IFNULL() 支持 支持 也可用 COALESCE()
JSON_EXTRACT() 支持 支持 语法相同
GROUP_CONCAT() 支持 支持 MariaDB 功能更丰富
COALESCE() 支持 支持
INSTR() 支持 支持
REPLACE() 支持 支持
LIKE 支持 支持 大小写敏感性不同

5.4 布尔值差异

  • SQLitebool 存储为 0/1
  • MariaDBGORM 使用 TINYINT(1),同样存储为 0/1
  • 无代码改动GORM 自动处理两种驱动的布尔值序列化

5.5 IFNULL vs COALESCE

代码中使用了 COALESCE(如 inbound.go:999

gorm.Expr("COALESCE(all_time, 0) + ?", traffic.Up+traffic.Down)

也使用了 IFNULL(如 inbound.go:2224

SET all_time = IFNULL(up, 0) + IFNULL(down, 0)

两者在 MariaDB 和 SQLite 中都支持,无需修改。


6. 入站管理 API数据库层

GET /panel/api/inbounds/list

数据库操作: GORM 标准查询,可移植。

db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Find(&inbounds)

MariaDB 无改动。


GET /panel/api/inbounds/get/:id

数据库操作: GORM First,可移植。

db.Model(model.Inbound{}).First(inbound, id)

MariaDB 无改动。


POST /panel/api/inbounds/add

数据库操作: GORM Save + Create,事务,可移植。

tx := db.Begin()
tx.Save(inbound)
tx.Create(&clientTraffic)
tx.Commit()

MariaDB 无改动。 InnoDB 事务支持良好。


POST /panel/api/inbounds/del/:id

数据库操作: GORM Delete + 级联删除客户端流量,可移植。

db.Where("inbound_id = ?", id).Delete(xray.ClientTraffic{})
db.Delete(model.Inbound{}, id)

MariaDB 无改动。


POST /panel/api/inbounds/update/:id

数据库操作: GORM Save + 事务,可移植。

MariaDB 无改动。 需注意 updateClientTraffics 内部的 GetClients 是应用层 JSON 解析,不涉及数据库特有 SQL。


POST /panel/api/inbounds/import

数据库操作: 接收 JSON 数据后通过 AddInbound 写入。

MariaDB 无改动。


7. 客户端管理 API数据库层

GET /panel/api/inbounds/getClientTraffics/:email

数据库操作: GORM 标准查询。

db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics)

MariaDB 无改动。


GET /panel/api/inbounds/getClientTrafficsById/:id

数据库操作: 使用 JSON_EACH,需改造。

改造方案见 5.1.3


POST /panel/api/inbounds/addClient

数据库操作: GORM Save 更新 inbound.Settings JSON 字段 + Create 客户端流量记录。

MariaDB 无改动。 客户端信息存储在 inbounds.settings 的 JSON 中,应用层解析。


POST /panel/api/inbounds/:id/delClient/:clientId

数据库操作: GORM Delete 删除客户端流量记录 + Save 更新入站 Settings。

MariaDB 无改动。


POST /panel/api/inbounds/updateClient/:clientId

数据库操作: GORM Updates 更新客户端流量 + Save 更新入站 Settings。

MariaDB 无改动。


POST /panel/api/inbounds/:id/delClientByEmail/:email

数据库操作: GORM Delete + Save,可移植。

MariaDB 无改动。


POST /panel/api/inbounds/delDepletedClients/:id

数据库操作: 使用 GROUP_CONCAT 和条件查询。

db.Model(xray.ClientTraffic{}).
    Where(whereText+" and ((total > 0 and up + down >= total) or ...)", id, now).
    Select("inbound_id, GROUP_CONCAT(email) as email").
    Group("inbound_id").
    Find(&depletedClients)

MariaDB 兼容: GROUP_CONCAT 在 MariaDB 中工作正常,无需改动


8. 流量管理 API数据库层

POST /panel/api/inbounds/:id/resetClientTraffic/:email

数据库操作: GORM Updates,可移植。

db.Model(xray.ClientTraffic{}).
    Where("email = ?", email).
    Updates(map[string]any{"enable": true, "up": 0, "down": 0})

MariaDB 无改动。


POST /panel/api/inbounds/resetAllTraffics

数据库操作: GORM Updates,可移植。

db.Model(model.Inbound{}).Where("user_id > ?", 0).
    Updates(map[string]any{"up": 0, "down": 0})

MariaDB 无改动。


POST /panel/api/inbounds/resetAllClientTraffics/:id

数据库操作: GORM 事务 + Updates,可移植。

db.Transaction(func(tx *gorm.DB) error {
    tx.Model(xray.ClientTraffic{}).Where(whereText, id).
        Updates(map[string]any{"enable": true, "up": 0, "down": 0})
    tx.Model(model.Inbound{}).Where(inboundWhereText, id).
        Update("last_traffic_reset_time", now)
    return nil
})

MariaDB 无改动。 InnoDB 事务支持良好。


POST /panel/api/inbounds/updateClientTraffic/:email

数据库操作: GORM Updates,可移植。

db.Model(xray.ClientTraffic{}).Where("email = ?", email).
    Updates(map[string]any{"up": upload, "down": download})

MariaDB 无改动。


addInboundTraffic — 原子递增

tx.Model(&model.Inbound{}).Where("tag = ?", traffic.Tag).
    Updates(map[string]any{
        "up":       gorm.Expr("up + ?", traffic.Up),
        "down":     gorm.Expr("down + ?", traffic.Down),
        "all_time": gorm.Expr("COALESCE(all_time, 0) + ?", traffic.Up+traffic.Down),
    })

MariaDB 兼容: gorm.Expr 直接生成 SQLCOALESCE 在 MariaDB 中支持。无需改动。


自动禁用逻辑 — disableInvalidClients / disableInvalidInbounds

// Join 查询
tx.Table("inbounds").
    Select("inbounds.tag, client_traffics.email").
    Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
    Where("((client_traffics.total > 0 AND ...) OR ...) AND client_traffics.enable = ?", now, true).
    Scan(&results)

MariaDB 兼容: 标准 SQL JOIN。无需改动。


9. IP 记录管理 API数据库层

POST /panel/api/inbounds/clientIps/:email

数据库操作: GORM First 查询。

db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps)

MariaDB 无改动。


POST /panel/api/inbounds/clearClientIps/:email

数据库操作: GORM Update

db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).Update("ips", "")

MariaDB 无改动。


10. 面板配置 API数据库层

POST /panel/setting/all

数据库操作: GORM Find 查询 settings 表,应用层通过反射映射到 AllSetting 结构体。

db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings)

MariaDB 无改动。 key 字段在 MariaDB 中是保留字,但 GORM 会自动加反引号。


POST /panel/setting/update

数据库操作: 逐字段 saveSettingFirstSaveCreate)。

MariaDB 无改动。


11. 用户管理 API数据库层

POST /panel/setting/updateUser

数据库操作: GORM First + Updates

db.Model(model.User{}).Where("username = ?", username).First(user)
db.Model(model.User{}).Where("id = ?", id).
    Updates(map[string]any{"username": username, "password": hashedPassword})

MariaDB 无改动。


认证与 LDAP

CheckUser 中的数据库查询使用 GORM 标准 APILDAP 认证不涉及数据库。MariaDB 无改动。


12. 数据库导入导出MariaDB 方案)

GET /panel/api/server/getDb

当前 SQLite 实现: 直接复制 x-ui.db 文件提供下载。

MariaDB 替代方案:

func (s *ServerService) GetDb(c *gin.Context) error {
    db := database.GetDB()
    sqlDB, _ := db.DB()

    // 方案 Amysqldump 逻辑(推荐)
    // 导出所有表的 SQL dump
    cmd := exec.Command("mysqldump",
        "-u", dbUser, "-p"+dbPassword,
        "-h", dbHost, "-P", dbPort,
        "--single-transaction",
        "--result-file=/tmp/xui-backup.sql",
        dbName,
    )
    if err := cmd.Run(); err != nil {
        return err
    }
    c.FileAttachment("/tmp/xui-backup.sql", "xui-backup.sql")

    // 方案 BJSON 导出(应用层)
    // 逐表读取数据并序列化为 JSON
    dump := map[string]any{}
    var inbounds []model.Inbound
    db.Find(&inbounds)
    dump["inbounds"] = inbounds
    // ... 其他表
    c.JSON(200, dump)
}

POST /panel/api/server/importDB

当前 SQLite 实现: 接收 .db 文件,覆盖当前数据库文件。

MariaDB 替代方案:

func (s *ServerService) ImportDB(file *multipart.FileHeader) error {
    // 方案 ASQL dump 导入
    cmd := exec.Command("mysql",
        "-u", dbUser, "-p"+dbPassword,
        "-h", dbHost, "-P", dbPort,
        dbName,
        "-e", "source /tmp/xui-import.sql",
    )
    return cmd.Run()

    // 方案 BJSON 导入
    // 解析 JSON逐表 truncate 后重新插入
}

13. 订阅服务的数据库查询改造

原始文件:sub/subService.go

getInboundsBySubId() — 需改造

详见 5.1.4

getFallbackMaster() — 需改造

详见 5.1.5

其他订阅逻辑

链接生成、流量统计、HTML 渲染等均为应用层逻辑,不涉及 SQLite 特有 SQL。MariaDB 无改动。


14. 数据库维护操作MariaDB 对应)

SQLite 操作 用途 MariaDB 等价操作
PRAGMA wal_checkpoint 刷写 WAL 日志 FLUSH TABLES
PRAGMA integrity_check 数据完整性检查 CHECK TABLE table_name
VACUUM 回收空间/碎片整理 OPTIMIZE TABLE table_name
PRAGMA journal_mode=WAL 日志模式 无需设置InnoDB 自带崩溃恢复)
文件复制备份 备份数据库 mysqldump --single-transaction
文件替换恢复 恢复数据库 mysql < backup.sql

InnoDB 原生优势

MariaDB 的 InnoDB 引擎自带以下特性,无需额外操作:

  • 崩溃恢复:通过 redo/undo 日志自动恢复
  • 行级锁:并发性能远优于 SQLite 的库级锁
  • 事务隔离:支持 READ COMMITTED、REPEATABLE READ 等级别

15. 迁移实施步骤

步骤 1安装 MariaDB 并创建数据库

# Ubuntu/Debian
apt install mariadb-server
mysql_secure_installation

# 创建数据库和用户
mysql -u root -p <<EOF
CREATE DATABASE xui CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'xui'@'localhost' IDENTIFIED BY 'your_secure_password';
GRANT ALL PRIVILEGES ON xui.* TO 'xui'@'localhost';
FLUSH PRIVILEGES;
EOF

步骤 2修改 Go 依赖

go get gorm.io/driver/mysql
# 可选:移除 SQLite 依赖(如完全不再需要)
# go mod tidy

步骤 3改造 database/db.go

支持双驱动(通过环境变量切换),保持向后兼容。

步骤 4改造原始 SQL 查询

按照 第 5 节 的方案,改造所有使用 JSON_EACHPRAGMAVACUUM 的代码。

步骤 5数据迁移

# 从 SQLite 导出数据(应用层)
# 然后导入到 MariaDB

# 或使用工具
apt install sqlite3
sqlite3 /etc/x-ui/x-ui.db .dump > xui-data.sql
# 手动调整 SQL 兼容性后导入
mysql xui < xui-data.sql

步骤 6测试验证

  • 启动面板,确认 AutoMigrate 正确创建所有表
  • 登录认证正常
  • CRUD 入站/客户端正常
  • 流量统计正确累加
  • 订阅链接生成正常
  • 客户端在线状态跟踪正常
  • 数据库导出/导入正常
  • 并发写入无锁冲突

附录 A完整代码改动清单

文件 改动类型 说明
go.mod 依赖替换 sqlitemysql driver
database/db.go 连接层改造 支持双驱动初始化
database/db.go 删除/改造 IsSQLiteDB, ValidateSQLiteDB, Checkpoint
config/config.go 新增环境变量 XUI_DB_DRIVER/HOST/PORT/NAME/USER/PASSWORD
web/service/inbound.go:144 SQL → 应用层 getAllEmails()
web/service/inbound.go:1313 SQL → 应用层 MigrationRemoveOrphanedTraffics()
web/service/inbound.go:2057 SQL → 应用层 GetClientTrafficByID()
web/service/inbound.go:2206 SQL → MariaDB MigrationRequirements() 中的 VACUUM
sub/subService.go:115 SQL → 应用层 getInboundsBySubId()
sub/subService.go:141 SQL → 应用层 getFallbackMaster()
web/controller/server.go 导入导出改造 getDb / importDB

附录 B风险评估

风险项 等级 缓解措施
JSON_EACH 无等价函数 改用应用层解析,可能影响大数据量性能
JSON 字段查询性能 MariaDB 原生 JSON 类型 + 虚拟列索引
数据迁移完整性 先导出 JSON 备份,再逐表验证行数
并发写入锁行为变化 InnoDB 行级锁,实际上优于 SQLite
大小写敏感性差异 统一使用 utf8mb4_unicode_ci 排序规则