第 4 章 数据库架构与核心业务模型深度解析
1 学习目标
学完本章你应该能够:
- 说出 13 张表的归属族群(资源统一模型族 / 类型明细 / 任务 / 账单 / 凭证),并指出每族的代表表,同时说清"表清单是从代码哪里反推出来的";
- 解释统一资源模型 + 垂直分表的设计:为什么
Resource拆成 meta/spec/cost/status 四张表,以及垂直分表带来的三个收益; - 说明
resource_id关联模式与ContentHash增量同步设计的原理——为什么"哈希没变不写库"; - 画出"凭证 → 任务 → 资源"三者的数据关联链路,并用它回答审计三问:谁、什么时候、同步了哪些资源;
- 说出多租户三件套(domain/namespace/env)与统一枚举(全项目的"普通话词典")分别解决什么问题。
前置知识
- 第 1~3 章的模块划分(resource / secret / task / host 等模块各自的职责);
- MySQL 基础:主键、索引、外键的区别;
- 知道 proto 生成的 Go message 长什么样(不要求会写 .proto)。
本章你会动手做的事
- 从
apps/resource/impl/model.go的 6 个TableName()和各模块impl/sql.go的 SQL 常量出发,亲手把 13 张表按族群归类数一遍; - 在 proto 的
ContentHash里找到六个 hash 字段,理解它们在resource_meta表上的落位; - 顺着
credential_id把 secrets → task → task_record → resource_meta 的血缘链在代码里走一遍。
先看本章的知识地图,心里有全景再进细节:
2 知识点通俗系统化讲解
2.1 先看全景:13 张表分成五个族群
先交代一个读库姿势:这个仓库没有把建表 DDL 文件随代码发布——cmd/init.go:68 里 init 子命令的默认参数是读取 docs/schema/tables.sql 执行,但仓库里并没有 docs/ 目录。所以表清单要从代码事实反推,依据有两处:
- GORM 的
TableName()方法:模型告诉 GORM 自己映射到哪张表(apps/resource/impl/model.go:84-139一口气定义了 6 张); - 各模块
impl/sql.go里的 SQL 常量:INSERT INTO xxx/FROM xxx里的表名是最诚实的证据。
把两处证据汇总,全项目一共 13 张表:
家族一:资源统一模型族(核心中的核心,6 张)
| 表 | 说明 | 证据 |
|---|---|---|
resource_meta | 资源基础信息(所有资源公共,全局检索入口) | model.go:84 常量 RESOURCE_META_TABLE_NAME |
resource_spec | 资源规格(厂商/类型/地域/配置等) | model.go:101 |
resource_status | 资源状态(phase/内外网 IP) | model.go:119 |
resource_cost | 资源费用(付费方式/价格) | model.go:110 |
resource_tag | 资源标签(1:N) | model.go:130 |
resource_relation | 资源关系(表映射定义了,代码未使用) | model.go:139 |
家族二:类型明细族(2 张):resource_host(主机专属:os/镜像/带宽/安全组,apps/host/impl/sql.go:4-10)、resource_rds(RDS 专属:引擎/端口/白名单,apps/rds/impl/sql.go:4-10)。
家族三:任务族(2 张):task(同步任务主表)、task_record(任务明细,每实例成功/失败),见 apps/task/impl/sql.go:4-25。
家族四:账单族(2 张):bill_raw(厂商月账单原始数据)、bill(按资源聚合后的账单),见 apps/bill/impl/sql.go。
家族五:凭证族(1 张):secrets(同步凭证,注意是复数表名,apps/secret/secret.go:5-7 的 TableName() 返回 "secrets")。
apps/host/impl/sql.go:20-34 的 queryHostSQL/countHostSQL、apps/rds/impl/sql.go:17 的 queryRdsSQL)还在 JOIN 一张叫 resource 的旧大表——这是垂直分表改造前的历史遗留 schema,仓库里已经没有它的模型定义。也就是说项目处于"写入走新表(meta/spec/cost/status 分表)、部分查询还引用旧表"的过渡态。真实开源项目就是这样长满藤蔓的,读的时候要分清"设计方向"和"迁移进度"。2.2 统一资源模型:为什么是 Meta+Spec+Cost+Status 四件套?
先看 proto 里定义的领域模型(apps/resource/resource.pb.go):
Resource
├── Meta → id / domain / namespace / env / sync_at / credential_id / create_at / usage_mode
├── Spec → vendor / resource_type / region / zone / owner / name / cpu / memory / tags / ...
├── Cost → pay_mode / 官网价 / 实付 / 折扣
├── Status → phase(运行状态) / public_ip / private_ip
├── ContentHash → spec_hash / cost_hash / status_hash / tag_hash / relation_hash / custom_hash
└── RelatedResources
• Meta 是行驶证:车牌号(id)、登记时间(create_at)、最近年检时间(sync_at)、归哪个车主公司(domain/namespace)——不管什么车,行驶证的栏目都一样;
• Spec 是配置单:品牌(vendor)、车型(resource_type)、排量(cpu)、座位数(memory)——不同车型配置单栏目差异大,但归纳成"规格"这一类;
• Cost 是购车发票:全款还是分期(pay_mode)、原价、成交价、折扣;
• Status 是仪表盘:当前状态(运行/熄火/故障)、行驶里程——高频变化的信息单独放;
• ContentHash 是车况指纹:把 Spec/Cost/Status 分别算一个哈希,下次同步时对比哈希就知道"这车有没有被改过",没变就不写库(增量同步的基础)。
对应到工程里就是:检索要用到的"身份与管理字段"进 Meta,描述"这台机器是什么"的进 Spec,"花多少钱"进 Cost,"现在跑得怎么样"进 Status——按变化频率和查询维度切表,而不是把所有字段堆在一起。
2.3 垂直分表:公共栏放主表,专属栏放明细表
四件套落到数据库就是四张表,加上类型明细表:
resource_meta (id = 云厂商资源ID,全局主键)
│ 1:1
├── resource_spec (resource_id) 公共规格
├── resource_cost (resource_id) 费用
├── resource_status (resource_id) 状态
├── resource_tag (resource_id) 标签(1:N)
│ 1:1(类型专属明细)
├── resource_host (resource_id) 主机专属:os/镜像/带宽/安全组
└── resource_rds (resource_id) RDS专属:引擎/端口/白名单
为什么要垂直分表,而不是一张大宽表? 三个理由:
- 扩展性:新增 MongoDB 资源类型,只需要加一张
resource_mongodb明细表,主模型四件套一个字段都不用改——如果是一张大宽表,每加一种资源就要 ALTER TABLE 加 N 个列,线上大表加列是高危操作; - 读写分离关注点:检索列表页只需要 meta+spec+cost+status(JOIN 四张表,
apps/resource/impl/dao.go:12-17的 BuildQuery 就是这么干的);状态频繁变化只 UPDATEresource_status一张窄表,锁范围小; - 空值管理:宽表里 RDS 的"引擎版本"对主机来说永远是 NULL,垂直分表天然规避了稀疏列。
主键设计的巧思:resource_meta.id 直接使用云厂商自己的资源 ID(如阿里云的实例 ID i-bp1xxxxx)。证据在 model.go:25-26:NewResource 一进来就取 res.Meta.Id 当 ResourceId 分发给各子表。好处:天然唯一、同步对账时无需自造映射;代价:不同厂商理论上有撞 ID 风险(实践上各云 ID 格式带前缀,风险极低)。
resource_relation:模型里定义了 TableName(model.go:139),但业务代码里没有任何地方读写它。读开源项目时遇到"建而未用"的表,先别急着骂脏设计——它往往是作者预留的扩展位(资源间关系建模),但也提醒你:表结构是设计意图,代码才是事实标准,两者要对齐着读。2.4 多租户三件套:domain / namespace / env
resource_meta 上有三个高频查询字段:domain(域,通常对应公司/事业群)、namespace(空间,通常对应项目组)、env(环境:开发/测试/生产)。secrets 表同样有 domain/namespace(apps/secret/pb/secret.proto:40-45)。这是多租户数据隔离的维度——同一套 CMDB 服务多家"租户",检索时按域过滤。它们从哪来?登录用户的 token 里带出来(apps/secret/interface.go:165 的 SetOwner(tk *token.Token) 就是把 token 的 Domain/Namespace 注入请求)。
2.5 凭证→任务→资源:一条"血缘链路"
secrets (凭证)
│ id
▼
task (任务) ── credential_id ──→ secrets.id 「我用哪个凭证去同步」(task/impl/sql.go:5-9)
│ id
▼
task_record (任务明细) ── task_id ──→ task.id 「这次同步每台机器的结果」(task/impl/sql.go:19-23)
resource_meta.credential_id ──→ secrets.id 「这条资源是哪个凭证同步来的」
这条链路回答了三个审计问题:谁(凭证)在什么时候(task.start_at/end_at)同步了哪些(task_record / resource_meta.credential_id)资源。CMDB 这类资产管理平台,可追溯性是刚需。
credential_id 反查就能还原完整链路——出审计报表时,这条链就是你的数据来源。2.6 关键表字段精讲(全部来自代码证据)
resource_meta(字段来自 apps/resource/resource.pb.go 的 Meta + ContentHash,由 ResourceMeta 结构体同表存储,见 model.go:78-89):
| 字段 | 含义 |
|---|---|
id | 全局唯一 Id,直接使用各云商自己的资源 Id |
domain/namespace/env | 多租户隔离三件套 |
sync_at | 最近一次同步时间 |
credential_id | 用哪个凭证同步的(回填 secrets.id) |
create_at | 创建时间 |
usage_mode | 使用方式:共享(0)/独占(1) |
spec_hash/cost_hash/status_hash/tag_hash/relation_hash/custom_hash | 各类数据 Hash,用于变更对比/增量同步 |
secrets(字段来自 apps/secret/pb/secret.proto:15-46,proto 里 Meta/CreateSecretRequest 都带 gorm:"embedded" 嵌入落库):id、create_at、description、vendor(哪家云)、address(vsphere 场景的 vCenter 地址)、regions(允许同步的地域,源码字段名就叫 regions,存库时逗号拼接,支持 *,见 interface.go:116 的 strings.Join(s.Regions, ","))、crendential_type(源码就是这个拼写,少个 e)、api_key、api_secret(密文,@ciphered@ 前缀)、request_rate(请求速率限制,默认 1 秒 5 个)、domain、namespace。
task(列清单来自 apps/task/impl/sql.go:4-10 的 insertTaskSQL):id、region、resource_type、credential_id、credential_desc、timeout、status、message、start_at、end_at、total_succeed、total_failed、domain、namespace——一张表同时记录"任务参数"(前几列)和"任务执行结果"(后几列)。task_record 则是:instance_id、instance_name、is_success、message、task_id、create_at。
bill_raw / bill(列清单来自 apps/bill/impl/sql.go):两张表列几乎一致——vendor、year、month、owner_id、owner_name、product_type、product_code、product_detail、pay_mode、order_id、resource_id、resource_name、public_ip、private_ip、instance_config、region_code、region_name、sale_price、save_cost、real_cost、credit_pay、voucher_pay、cash_pay、storedcard_pay、outstanding_amount、is_merged、task_id。mergeBillSQL 把 bill_raw 按 resource_id 分组、金额列 sum() 聚合后写入 bill——“原始流水 → 按资源汇总"的两级账单设计。
resource_host(列清单来自 apps/host/impl/sql.go:4-10):resource_id、gpu_spec、os_type、os_name、image_id、internet_max_bandwidth_out、internet_max_bandwidth_in、key_pair_name、security_groups。
resource_rds(列清单来自 apps/rds/impl/sql.go:4-10):resource_id、engine_type、engine_version、instance_class、class_type、export_type、network_type、type、db_max_quantity、account_max_quantity、max_connections、max_iops、collation、time_zone、storage_type、security_ip_mode、security_ip_list、connection_mode、ip_type、deploy_mode、port、extra。
2.7 枚举:全项目的"普通话词典”
统一模型靠枚举抹平厂商差异(数值来自 apps/resource/resource.pb.go 等生成代码):
| 枚举 | 取值(节选) |
|---|---|
VENDOR | ALIYUN=0 TENCENT=1 HUAWEI=2 VSPHERE=3 AMAZON=4 |
TYPE | HOST=0 RDS=1 REDIS=2 BUCKET=3 DISK=4 LB=5 DOMAIN=6 EIP=7 MONGODB=8 DATABASE=100 ACCOUNT=101 BILL=200 ORDER=201 |
PAY_MODE | PRE_PAY=0(预付费/包年包月)POST_PAY=1(按量付费)RESERVED_PAY=2 SPOT_PAY=3(竞价)NULL=9 |
USAGE_MODE | SHARED=0 MONOPOLY=1 |
TAG_PURPOSE | GROUP=0 SYSTEM=1 MONITOR=2 THIRDPARTY=3 |
STATUS(host) | UNKNOW=0 RUNNING=11 STOPPED=21 ERROR=50 LOCKED=70 DESTROYED=90 … |
secret.TYPE | API_KEY=0 PASSWORD=1 |
task.Type | RESOURCE_SYNC=0 RESOURCE_RELEASE=1 |
举例说明"归一化"的意义:阿里云把"运行中"叫 Running,腾讯云叫 RUNNING,华为云传字符串 ACTIVE——mapping 层把它们统统翻译成 STATUS_RUNNING=11 存库。库里永远只有普通话(统一枚举),方言(厂商私有值)不出 provider 层。
WHERE phase = 'Running'——直接报错或查不到任何数据。库里存的永远是统一枚举数值(RUNNING=11),厂商原始字符串在 provider 的 mapping 层就被翻译掉了。对应到工程里就是:排查数据问题前,先回这张"普通话词典"表对一对枚举值,再去看厂商文档里的方言。3 核心代码片段逐行解析
3.1 片段:proto 模型 → GORM 持久化模型的映射(apps/resource/impl/model.go:25-61)
// NewResource:把 proto 的 Resource "翻译"成可分表存储的 temp 结构
func NewResource(res *resource.Resource) *Resource {
rid := &ResourceId{ResourceId: res.Meta.Id} // 外键载体:各子表都要带 resource_id
temp := &Resource{
// meta 表:Meta + ContentHash 一起存(同表不同列)
ResourceMeta: &ResourceMeta{
Meta: res.Meta,
ContentHash: res.ContentHash,
},
// spec 表:resource_id 外键 + Spec 数据
ResourceSpec: &ResourceSpec{
ResourceId: rid,
Spec: res.Spec,
},
ResourceCost: &ResourceCost{
ResourceId: rid,
Cost: res.Cost,
},
ResourceStatus: &ResourceStatus{
ResourceId: rid,
Status: res.Status,
},
Tags: []*ResourceTag{}, // 标签单独收集
}
// Spec 里挂着的 Tags 拆出来,逐个包成 ResourceTag(带 resource_id)
for i := range res.Spec.Tags {
temp.Tags = append(temp.Tags, &ResourceTag{
ResourceId: rid,
Tag: res.Spec.Tags[i],
})
}
return temp
}
// Resource 聚合结构:同时"伪装"成四张表的行
type Resource struct {
*ResourceMeta
*ResourceSpec
*ResourceCost
*ResourceStatus
Tags []*ResourceTag
}
再看每个子结构怎么"告诉"GORM 自己属于哪张表:
type ResourceMeta struct {
*resource.Meta // 嵌入 proto 生成的 message(复用字段定义)
*resource.ContentHash // 同上:hash 列也在 meta 表
}
func (s *ResourceMeta) TableName() string {
return RESOURCE_META_TABLE_NAME // "resource_meta",GORM 钩子:决定 CRUD 的目标表
}
type ResourceSpec struct {
*ResourceId // 提供 resource_id 列
*resource.Spec
}
func (s *ResourceSpec) TableName() string { return "resource_spec" }
设计精髓:GORM 模型嵌入 proto message 而不是重新定义一份字段——proto 改了字段,持久化模型自动跟着变,消灭了"两处维护"。
3.2 片段:host 如何复用统一模型(apps/host/app.go:21-26)
// host 的领域对象:统一模型 + 主机专属描述
func NewDefaultHost() *Host {
return &Host{
Resource: resource.NewDefaultResource(resource.TYPE_HOST), // 复用!类型标记为 HOST
Describe: &Describe{}, // 主机专属明细
}
}
proto 定义里 Host 持有 *resource.Resource 字段——组合而非继承。主机 = 统一资源 + 主机明细,RDS 也是同样套路(统一资源 + RDS 明细)。
3.3 片段:Hash 的生成(apps/host/app.go:70 + utils/hash.go)
func (h *Host) GenHash() error {
// Spec 的哈希 + 主机明细(Describe)的自定义哈希
h.Resource.ContentHash.SpecHash = h.Resource.Spec.Hash()
h.Resource.ContentHash.CustomHash = utils.Hash(h.Describe)
return nil
}
Spec.Hash() 由 proto 生成代码实现(对整个 Spec 结构序列化后取哈希)。用途:同步时先查库里那条记录的 hash,与新数据的 hash 对比——相同说明没变化(可跳过写库),不同才更新。这就是 resource_meta 里六个 hash 列的意义:用空间换时间,跳过无变化的写入。
4 业务逻辑流程图与核心要点总结
4.1 数据库全景关系图
┌──────────────────┐
│ secrets (凭证) │ api_secret 密文(@ciphered@)
└────┬────────┬────┘
credential_id│ │1:N
│ ▼
┌────────────────▼──┐ ┌─────────────┐ ┌───────────────┐
│ resource_meta │ │ task │────│ task_record │
│ (id=云厂商ID) │ │ (同步任务) │ 1:N │ (每实例成败) │
└───┬────┬────┬────┬─┘ └───────────────┘ └───────────────┘
1:1 │ │ │ │ resource_id
┌────────▼┐ ┌─▼────────┐ ┌▼──────────┐ ┌▼──────────────┐ ┌▼─────────────┐
│resource_│ │resource_ │ │resource_ │ │resource_tag │ │resource_host │
│spec │ │cost │ │status │ │(1:N) │ │resource_rds │
└─────────┘ └───────────┘ └───────────┘ └───────────────┘ └───────────────┘
账单族: bill_raw ──sum()按resource_id聚合──▶ bill
关系表: resource_relation(定义了映射,业务代码未使用)
遗留表: resource(host/rds 的查询 SQL 仍 JOIN 它,垂直分表前的旧 schema)
4.2 核心要点总结
- 13 张表五个族群:统一模型 6 张、类型明细 2 张、任务 2 张、账单 2 张、凭证 1 张(
secrets是复数表名)——表清单从TableName()+impl/sql.go反推,DDL 文件未随仓库发布(cmd/init.go:68默认读docs/schema/tables.sql,该目录不存在); - 垂直分表三收益:加类型不动主模型、窄表 UPDATE 锁范围小、无稀疏 NULL 列;
resource_meta.id直接用云厂商 ID,天然对账键;- 六个 hash 列支撑增量同步:“哈希没变不写库”;
- 凭证→任务→资源血缘链路是审计刚需;
- 库里只存统一枚举(普通话),厂商方言被 mapping 挡在 provider 层。
5 自测题(对话式)
学完不等于学会。下面三道题是原书课后思考题的"面试现场版"——先自己作答,再看候选人的参考答案。
cmd/init.go 默认读的 docs/schema/tables.sql 并不存在)。如果你要本地跑起来,怎么知道该建哪些表?secret 模块的 TableName() 返回的是 "secrets"(复数),这里藏着什么坑?TableName()(resource 模块 model.go:84-139 定义了 6 张 resource 族表,secret 模块返回 "secrets");② 各模块 impl/sql.go 里的 SQL 常量(INSERT INTO / FROM 后面的表名),凑齐 host/rds/task/task_record/bill/bill_raw 共 13 张。自己照着模型字段写 DDL,或用 -s 参数把 init 指到自己的 SQL 文件。secrets 的坑:表名以代码为准是复数——如果你凭直觉建了单数表
secret,GORM 一执行就报 Table 'cmdb.secrets' doesn't exist。这引出一个通用纪律:开源项目没有 DDL 时,TableName() 和 SQL 常量就是唯一事实来源,别猜。resource_meta.id 直接使用云厂商 ID 有"撞 ID"的理论风险。你会如何验证这个风险是否真实存在?如果要做防御,主键方案可以怎么改、代价是什么?防御方案:主键改用自增 id 或 xid,把厂商 ID 挪到
(vendor, resource_id) 唯一索引上。代价是失去"主键即对账键"的便利——同步对账时不能直接按主键 upsert,得先按唯一索引查到内部主键再更新,多一次查询或改用 ON DUPLICATE KEY 类能力,同步路径变复杂。这是"省一次映射"与"主键空间自主可控"之间的取舍。resource.Put 目前每次同步都会 Save 全量数据(第 5 章会看到)。结合 resource_meta 的 hash 列,设计一个"哈希未变化则跳过写库"的增量同步方案,需要改动哪些函数?① resource 模块的 impl 里需要一个按 ID 查询旧记录的方法(类似 host 模块的 DescribeHost 按 Meta.Id 查重);② 修改 service.Put 的入口逻辑:开事务前先查旧记录,把新数据的六个 hash(spec/cost/status/tag/relation/custom)与库内值逐一对比——全相同直接 return 跳过写库;任一不同才走原有 tx.Save 流程;③ 同步侧配合:task.doSyncHost 调 SyncHost 前先调 GenHash 算好新数据哈希(host 模块 app.go:70 已有此方法,填 SpecHash 和 CustomHash),保证对比双方"口径一致"。
两个语义取舍要明确:一是跳过写库时 sync_at 是否刷新(不刷新可保留"真实变更时间",刷新则反映"最近确认时间");二是对比必须覆盖六类哈希,漏一类就会漏更新。收益就是原书说的:用空间换时间,跳过无变化的写入。
6 本章小结
resource_meta 是宇宙中心:Meta/Spec/Cost/Status 四件套垂直分表 + resource_id 关联 + 六个 hash 列做"哈希没变不写库"的增量同步;
- 表清单的出处是 TableName() 与 impl/sql.go——DDL 文件没随仓库发布,"代码才是事实标准";host/rds 的查询 SQL 还引用着垂直分表前的旧 resource 大表,读代码要分清设计方向与迁移进度;
- 凭证→任务→资源的血缘链路回答审计三问(谁/何时/哪些),多租户靠 domain/namespace/env 三件套隔离,库里永远只存统一枚举(普通话),方言不出 provider 层;
- 拿着这张"数据地图",下一章我们去精读路由、控制器、Service、DAO 各层的源码——你会看到第 5 章的 DAO 层代码就是把本章的表结构"翻译"成 Go 调用,届时会非常顺滑。