第 6 章 核心业务流程逐段拆解讲解

2025-12-08T10:00:00+08:00 | 17分钟阅读 | 更新于 2025-12-08T10:00:00+08:00

@

第 6 章 核心业务流程逐段拆解讲解

1 学习目标

学完本章你应该能够:

  1. 完整走通资源同步主链路:CreateTask → 解密凭证 → 分页拉云 → 逐台入库 → 任务记账,并说出每一步对应的源码位置;
  2. 走通资源检索链路:参数绑定 → 标签选择器解析(四种操作符)→ JOIN 查询,并解释为什么带标签筛选时 JOIN 方向要从 LEFT 切成 RIGHT;
  3. 走通凭证录入与加密链路:校验 → CBC 加密 → 落库,说清 xid 作为凭证 ID 的用意;
  4. 解释 pager.Pager 抽象与对账(先查后写)的职责边界——provider 只"翻译数据",对账职责完全在业务层;
  5. 指出同步链路的三个健壮性缺口:30 分钟硬超时、无法外部取消、先 go 后 insert 的竞态窗口。

前置知识

  • 第 4 章的数据模型:hash 列、血缘链路、多租户三件套;
  • 第 5 章的分层结构:Controller 三段式、Service 两种装配、DAO 两范式(本章会反复用到);
  • Go 基础:goroutine、context.WithTimeout、defer/recover 的语义。

本章你会动手做的事

  1. 跟着 6.2.1 的全景图,在编辑器里从 CreateTask 一路点跳到 doSyncHost,把五个函数的文件行号标出来;
  2. 用 tag=env=~prod&tag=team=bigdata 实际调一次检索接口,对照四种操作符看解析结果;
  3. 录入一条测试凭证,去库里确认 api_secret 带上了 @ciphered@ 前缀。

先看本章知识地图——三条主动脉加两个核心抽象:

第 6 章总览同步主链路pager 抽象对账 Reconcile检索与凭证建任务+前置检查异步 go syncHost逐台记账 task_recordNext+Scan 两步翻页细节被封装VMware 回调特例先查后写按云厂商 ID 查重provider 零写库标签四操作符RIGHT JOIN 切换xid+CBC 加密

2 知识点通俗系统化讲解

2.1 资源同步:CMDB 的"主动脉"

同步是 CMDB 存在的意义。整个链路横跨 5 个模块(task 编排 → secret 解密 → provider 采集 → host 对账 → resource 落库),我们先把全景图挂出来,然后逐段精讲:

① 客户端 → gRPC/HTTP CreateTask(指定: 凭证ID + 资源类型 + 地域)
② task.CreatTask          apps/task/impl/task.go:26
   ├─ task.NewTaskFromReq          生成 Task(id 用 xid)
   ├─ s.secret.DescribeSecret      按凭证ID取凭证
   ├─ 校验 region 是否在凭证允许范围
   ├─ secret.DecryptAPISecret      解密出明文 SK
   └─ switch ResourceType: TYPE_HOST → go s.syncHost(...)
③ task.syncHost           apps/task/impl/host.go:23
   ├─ t.Run() 标记任务开始
   ├─ switch secret.Vendor: 按厂商 new 对应 Operator
   └─ for pager.Next(): pager.Scan(ctx, set) → 逐台 doSyncHost
④ task.doSyncHost         apps/task/impl/host.go:116
   ├─ ins.Resource.Meta.SyncAt = now
   ├─ s.host.SyncHost(ctx, ins)    调 host 模块 upsert
   └─ 成功/失败 → task_record 落一条明细
⑤ host.SyncHost           apps/host/impl/host.go:14
   ├─ DescribeHost 按 Meta.Id 查重
   ├─ 存在 → update;不存在 → save(对账)
   └─ 写 resource_host 表
⚠️ 新手必踩的坑:注意 ②里的 go s.syncHost(...)——同步是异步的!CreateTask 接口立刻返回任务对象(此时状态是"进行中"),真正的拉取在后台 goroutine 里跑,调用方要靠 QueryTask/DescribeTask 轮询任务状态。新手常犯两个错:一是调完 CreateTask 就去查库,抱怨"怎么还没数据";二是以为接口返回失败等于同步失败——其实返回的只是"任务创建"这一步的成败。代价还有:同步上下文硬编码 30 分钟超时(apps/task/impl/task.go:58 的 time.Minute*30),且没有重试机制——这些健壮性缺口第 8 章展开。

2.2 分页器抽象:云端的"水龙头"

为什么 provider 层返回 pager.Pager 而不是一次拉全量?因为你不知道对方面前有多少数据——云上可能有几万台主机,一次拉回来内存直接爆掉。pager.Pager 接口(mcube 提供)把"逐页拉取"抽象成两步:

for pager.Next() {                  // 还有下一页吗?
	set := host.NewHostSet()
	if err := pager.Scan(ctx, set); err != nil {   // 拉当前页到 set
		...
	}
	for i := range set.Items {      // 逐台处理
		s.doSyncHost(ctx, set.Items[i], t)
	}
}

厂商侧的实现(以阿里云 ECS 为例,provider/aliyun/ecs/ecs_pager.go):

func (p *ecsPager) Scan(ctx context.Context, set pager.Set) error {
	// 调阿里云 SDK 拉当前页
	resp, err := p.operator.queryInstance(p.nextReq())
	if err != nil {
		return err
	}
	p.CheckHasNext(resp)            // 根据返回条数判断是否还有下一页
	set.Add(resp.ToAny()...)        // 翻译后的数据塞进 set
	return nil
}

任务层完全不知道阿里云的 PageNumber/PageSize 怎么传——这些细节被封在 nextReq() 里。这就是"云端分页与本地处理解耦":今天接 AWS(用 token 翻页)、明天接 VMware(根本没分页),任务层代码一行不改。

VMware 特例:vSphere 的 API 没有页的概念,所以走回调式——operator.QueryHost(callback) 每查到一台就回调一次,回调里直接 doSyncHost(apps/task/impl/host.go:81-85)。抽象层允许两种姿势并存。

类比:pager 就像一根带阀门的水管:你只负责"拧开阀门接一桶"(Scan)和"看还有没有水"(Next),自来水厂用什么泵、什么管网(阿里云的 PageNumber、AWS 的 token)你不用关心。对应到工程里就是"云端分页与本地处理解耦":任务层手上的循环是恒定的 for Next() { Scan(); 处理 },换厂商只换水管,不换接水的姿势;VMware 那根"没有桶、一滴一滴滴给你"的特例管子,也照样能接进同一个水池。

2.3 对账(Reconcile):同步的"灵魂步骤"

拉回来数据直接 INSERT 行不行?不行——第二次同步会把重复数据插成两行。对账 = 让库里数据和云端事实一致:

// apps/host/impl/host.go:14-41 —— SyncHost 就是"对账"的落地
func (s *service) SyncHost(ctx context.Context, ins *host.Host) (*host.Host, error) {
	// 按云厂商 ID(Meta.Id)查库里有没有这条
	exist, err := s.DescribeHost(ctx, host.NewDescribeHostRequestWithID(ins.Resource.Meta.Id))
	if err != nil {
		// 查库出错(且不是"不存在"错误)→ 直接失败返回
		if !exception.IsNotFoundError(err) {
			return nil, err
		}
	}

	if exist != nil {                    // 库里已有 → 更新
		exist.Put(host.NewUpdateHostDataByIns(ins))
		if err := s.update(ctx, exist); err != nil {
			return nil, err
		}
		return ins, nil
	}
	// 库里没有 → 新增
	if err := s.save(ctx, ins); err != nil {
		return nil, err
	}
	return ins, nil
}

大白话:先查后写(存在即更新,不存在即插入)——这就是最朴素的 upsert 对账。底稿特别强调一个事实:provider/ 层不含任何写库/对账逻辑(全目录 grep reconcile|Sync|SaveOrUpdate 无匹配),provider 只负责"翻译数据",对账职责完全在业务层。单一职责不是口号,是真的按层切干净的。

类比:对账就是仓库盘点:新到一批货(云端数据),不能直接往货架上堆——先翻台账(DescribeHost 按 Meta.Id 查重),台账有这批货的记录就更新数量(update),没有才登记新条目(save)。对应到工程里就是:CMDB 里"云端是唯一事实源、数据库是本地账本",每次同步都是一次小型盘点;而 provider 层只是"送货翻译员"——把阿里云/腾讯云的包装单翻译成标准单据,绝不自己动账本,动账本的永远是仓库管理员(业务层)。

2.4 数据流转总览:一条主机数据的"翻译之旅"

阿里云 DescribeInstances 响应(阿里方言:Status="Running", InstanceChargeType="PostPaid")
  → provider/aliyun/ecs 的 transferInstance
       状态: "Running" --ALIYUN_STATUS_MAP--> host.STATUS_RUNNING(11)
       付费: "PostPaid" --mapping.PrasePAY_MODE--> resource.PAY_MODE_POST_PAY(1)
       填充 Meta(Id/Domain...)/Spec(Vendor=ALIYUN...)/Status/Cost/Describe
  → *resource.Resource(统一普通话模型,包在 host.Host 里)
  → task.doSyncHost 补 Meta.SyncAt + InjectBaseFromSecret(补凭证信息)
  → host.SyncHost 对账 upsert
  → resource_host 表(host 模块,原生 SQL)
  → 同时 resource 模块的 Put 可写 resource_meta/spec/cost/status(GORM)
类比:归一化 mapping 是多云平台的"海关":所有方言值(状态字符串、付费模式字符串)在海关处统一兑换成普通话枚举,之后全系统只流通普通话。加一家新云 = 新开一个海关窗口(实现 operator + mapping),境内(业务层)零改动。对应到工程里就是:阿里云的 "Running"、腾讯云的 "RUNNING"、华为云的 "ACTIVE" 在 provider 层就被翻译成 STATUS_RUNNING(11),第 4 章那张"普通话词典"表在这里完成兑换——境内(库、检索、报表)永远只见到统一枚举。

2.5 检索链路:标签选择器的花样玩法

检索接口支持一种很实用的标签查询语法(apps/resource/interface.go:202-218):

GET /cmdb/api/v1/resource/search?tag=env=~prod,stag&tag=team=bigdata

含义:标签 env 的值 LIKE 匹配 prod 或 stag,并且标签 team 精确等于 bigdata。解析器 ParExpr 支持 4 种操作符(interface.go:155-160):

写法操作符SQL 语义
key=v1=t_value = v1(多值逗号分隔为 OR)
key!=v1!=t_value != v1(多值为 AND)
key=~v1=~t_value LIKE %v1%
key!~v1!~t_value NOT LIKE %v1%

值还支持 * 通配(只按 key 匹配)与 .* 转百分号(dao.go:52,59 的 strings.ReplaceAll(selector.Key, ".*", "%"))。多值之间的连接关系由 RelationShip() 决定:等值类用 OR、非等值类用 AND(interface.go:220-229)——语义上很讲究:env!=a,b 应该是"既不是 a 也不是 b"(AND),而 env=a,b 是"a 或 b 都行"(OR)。

另外,带标签筛选时 JOIN 方向会从 LEFT 变 RIGHT(dao.go:43-44):RIGHT JOIN resource_tag tag ON tag.resource_id = m.id——因为此时"有这个标签"是硬条件,必须以 tag 表为基准反查资源,LEFT JOIN 会把没标签的资源也带出来。

⚠️ 新手必踩的坑:LEFT/RIGHT JOIN 的切换最容易被忽略——很多人给检索加标签过滤时随手在原有 LEFT JOIN 后面 Where 一句 tag 条件,结果查出来一堆空行或漏数据。原因是:LEFT JOIN 以主表为基准,没标签的资源也会保留(tag 列为 NULL),此时再 WHERE tag.t_value=... 会把 NULL 行筛掉但语义已经拧巴;正确姿势是像本项目一样把 JOIN 方向切成 RIGHT,以 tag 表为基准反查。口诀:标签是"硬条件"就以标签表为基准(RIGHT JOIN),标签是"附加信息"才用 LEFT JOIN。另外别忘了第 5 章的别名铁律:dao.go 里 tag 相关条件的前缀必须与新声明的别名一致。

2.6 凭证录入链路:三行核心代码

POST /cmdb/api/v1/secrets/  {vendor: "aliyun", api_key: "LTAI...", api_secret: "明文", regions: ["cn-hangzhou"]}
  → CreateSecret handler(apps/secret/api/http.go:40)
  → secret.CreateSecret(apps/secret/impl/secret.go:11)
       ├─ secret.NewSecret(req)          校验(Regions 必填至少一个)
       ├─ ins.Spec.EncryptAPISecret(key) CBC加密+base64+前缀  ← 第5章片段2
       └─ s.db.WithContext(ctx).Create(ins)  GORM 落库

注意 NewSecret 里会生成 Meta.Id = xid.New().String()(apps/secret/interface.go:152-157)——凭证 ID 不是自增、不是 UUID v4,而是 xid(时间有序的短 ID),兼顾唯一性与排序友好。

类比:xid 就像银行叫号机的号条:每张唯一,且天然按取号时间排序——翻凭证列表时"最新录入的排前面"不需要额外字段。对应到工程里就是:任务表、凭证表这类"按时间翻阅"的数据用 xid 比自增主键多跨库安全,比 UUID v4 多一层"时间有序";而资源的唯一键则是第 4 章讲的"直接用云厂商 ID"——同一个项目里两种主键策略并存,各服务各自的最痛处。

3 核心代码片段逐行解析

3.1 片段:CreatTask 的"前置检查五连"(apps/task/impl/task.go:26-74 节选)

func (s *service) CreatTask(ctx context.Context, req *task.CreateTaskRequst) (
	*task.Task, error) {
	// ① 构造任务对象(生成 xid 任务 ID)
	t, err := task.NewTaskFromReq(req)
	if err != nil {
		return nil, err
	}

	// ② 取凭证:跨模块调用 secret 服务
	secretIns, err := s.secret.DescribeSecret(ctx, secret.NewDescribeSecretRequest(req.CredentialId))
	if err != nil {
		return nil, err
	}

	secret := secretIns.Spec
	t.UpdateSecretDesc(secret.ShortDesc())     // 任务上记录凭证描述(ShortDesc 带掩码)

	// ③ 地域白名单校验:vSphere(私有云无地域概念)和账单同步除外
	if !(secret.Vendor.Equal(resource.VENDOR_VSPHERE) || req.ResourceType.IsIn(resource.TYPE_BILL)) {
		if req.Region == "" {
			return nil, exception.NewBadRequest("region required")
		}
		if !secret.IsAllowRegion(req.Region) {
			// 凭证只授权了 cn-hangzhou,你却要同步 cn-beijing → 拒绝
			return nil, exception.NewBadRequest("this secret not allow sync region %s", req.Region)
		}
	}

	// ④ 解密凭证(解密结果只在本函数调用链的内存中存在)
	err = secret.DecryptAPISecret(conf.C().App.EncryptKey)
	if err != nil {
		s.log.Warnf("decrypt api secret error, %s", err)
	}

	// ⑤ 异步启动同步(30 分钟硬超时),任务先落库再返回
	syncCtx, _ := context.WithTimeout(context.Background(), time.Minute*30)
	switch req.ResourceType {
	case resource.TYPE_HOST:
		go s.syncHost(syncCtx, secretIns, t, s.SyncTaskCallback)
	case resource.TYPE_BILL:
		go s.syncBill(syncCtx, secretIns, t, s.SyncTaskCallback)
	case resource.TYPE_RDS:
		go s.syncRds(syncCtx, secretIns, t, s.SyncTaskCallback)
	}

	if err = s.insert(ctx, t); err != nil {    // 任务入库(此时状态=进行中)
		return nil, err
	}
	return t, nil
}

值得咀嚼的三个设计:

  • 地域白名单在凭证上(regions 字段支持 * 通配,interface.go:86-98 的 IsAllowedRegion),一个 AK 只授权它该管的地域——最小权限原则的落地;
  • syncCtx 丢弃了 cancel 函数(_),意味着这个 30 分钟超时只能等它自然到期,无法外部取消——健壮性缺口之一;
  • 任务是先 go 后 insert:极端情况下 goroutine 可能先于 insert 执行完(同步很快时),此时 SyncTaskCallback 更新一条还没插入的任务——小概率竞态,真实项目里值得留意。

3.2 片段:syncHost 的"厂商分发 + panic 兜底"(apps/task/impl/host.go:23-113 节选)

func (s *service) syncHost(ctx context.Context, secretIns *secret.Secret,
	t *task.Task, cb SyncTaskCallback) {
	var (
		pager pager.Pager
	)

	t.Run()                        // 任务状态 → RUNNING
	defer func() {
		if err := recover(); err != nil {
			t.Failed(fmt.Sprintf("pannic, %v", err))   // panic 不会丢任务(注:源码拼写 pannic)
		} else {
			t.Completed()
		}
		cb(t)                       // 无论成败,回调更新任务状态
	}()

	secret := secretIns.Spec
	req := provider.NewQueryRequestWithRate(secret.RequestRate)  // 用凭证上的限速值

	switch secret.Vendor {          // 厂商分发:一个 case 一家云
	case resource.VENDOR_ALIYUN:
		op, err := aliyun.NewOperator(secret.ApiKey, secret.ApiSecret, t.Data.Region)
		if err != nil {
			t.Failed(err.Error())
			return
		}
		pager = op.HostOperator().PageQueryHost(req)   // 拿到分页器
	case resource.VENDOR_TENCENT:
		op, err := txyun.NewOperator(secret.ApiKey, secret.ApiSecret, t.Data.Region)
		// ……(同构代码,华为云/AWS 同理)
	case resource.VENDOR_VSPHERE:
		// VMware 特例:回调式采集,不走 pager
		client := vsConn.NewVsphereClient(secret.Address, secret.ApiKey, secret.ApiSecret)
		ec, err := client.VimClient()
		if err != nil {
			t.Failed(err.Error())
			return
		}
		operator := vmOp.NewVMOperator(ec)
		err = operator.QueryHost(func(h *host.Host) {
			h.Resource.Meta.CredentialId = secretIns.Meta.Id   // 回填凭证 ID
			s.doSyncHost(ctx, h, t)                            // 每台机器直接入库
		})
		if err != nil {
			t.Failed(err.Error())
			return
		}
	default:
		t.Failed(fmt.Sprintf("unsuport vendor %s", secret.Vendor))
		return
	}

	// 分页拉取(仅非 VMware 厂商)
	if pager != nil {
		for pager.Next() {
			set := host.NewHostSet()
			if err := pager.Scan(ctx, set); err != nil {
				t.Failed(fmt.Sprintf("sync error, %s", err))
				return
			}
			for i := range set.Items {
				target := set.Items[i]
				// 补充管理信息(domain/namespace/credential_id 等来自凭证)
				InjectBaseFromSecret(target.Resource.Meta, secretIns)
				s.doSyncHost(ctx, target, t)
			}
		}
	}
}

两个细节:

  • InjectBaseFromSecret:云厂商只知道机器本身,不知道"这机器归哪个域哪个空间"——这些管理属性来自凭证(谁录入的凭证,同步来的资源就归属谁的 domain/namespace)。多云数据落库前完成"管理信息注入";
  • defer 里的 recover + cb:同步 goroutine 崩了也不会让任务永远停在"进行中"——panic 被捕获并转成任务失败状态。但底稿也指出:recover 只保状态不丢,不会重试,失败后需要人工重新发任务。

3.3 片段:doSyncHost —— 单台机器的"入库 + 记账"

// apps/task/impl/host.go:116-135 —— Host 主机数据入库
func (s *service) doSyncHost(ctx context.Context, ins *host.Host, t *task.Task) {
	// 刷新同步时间戳(doSyncHost 在第4步被调用)
	ins.Resource.Meta.SyncAt = time.Now().Unix()
	// 调 host 模块对账入库(存在即更新,不存在即插入)
	h, err := s.host.SyncHost(ctx, ins)

	// 无论成败,都往 task_record 记一条明细——审计的粒度是"单台机器"
	var detail *task.Record
	if err != nil {
		s.log.Warnf("save host error, %s", err)
		detail = task.NewSyncFailedRecord(t.Id, ins.Resource.Meta.Id,
			ins.Resource.Spec.Name, err.Error())
	} else {
		s.log.Debugf("save host %s to db", h.ShortDesc())
		detail = task.NewSyncSucceedRecord(t.Id, ins.Resource.Meta.Id,
			ins.Resource.Spec.Name)
	}

	t.AddDetail(detail)                        // 内存中的任务计数 +1
	if err := s.insertTaskDetail(ctx, detail); err != nil {
		s.log.Errorf("update detail error, %s", err)
	}
}

失败不中断:某台机器入库失败,只记一条失败明细,循环继续处理下一台——同步 500 台挂 1 台,任务仍算完成(total_failed=1),单点失败不会拖垮整批。这是批量任务处理的基本修养。

4 业务逻辑流程图与核心要点总结

4.1 同步全链路时序图

调用方          task模块         secret模块       provider(aliyun)    host模块      MySQL
  │ CreateTask    │                │                 │                │            │
  ├──────────────→│                │                 │                │            │
  │               │ DescribeSecret │                 │                │            │
  │               ├───────────────→│                 │                │            │
  │               │←─ Secret(密文) ┤                 │                │            │
  │               │ DecryptAPISecret(内存中解密)      │                │            │
  │               │ insert task(进行中) ──────────────────────────────────────────→ │
  │←─ Task(立即返回)│                │                │                │            │
  │               │ go syncHost ────────────────────→│                │            │
  │               │   (goroutine, 30min超时)          │                │            │
  │               │        for pager.Next()          │                │            │
  │               │           pager.Scan ───────────→│ DescribeInstances(第N页)     │
  │               │           ←── []*Host(已归一化) ──┤                │            │
  │               │        InjectBaseFromSecret       │                │            │
  │               │        doSyncHost ────────────────────────────────→│            │
  │               │                                   │    DescribeHost(按云ID查重)→│
  │               │                                   │    update or save ────────→│
  │               │        insert task_record(成败明细) ──────────────────────────→ │
  │               │   defer: t.Completed() + cb(t) 更新任务终态 ───────────────────→ │
  │ (轮询) DescribeTask ─→ 任务状态 + total_succeed/total_failed                    │

4.2 核心要点总结

  1. 同步五步:建任务 → 取凭证+校验+解密 → 异步分页拉取 → 逐台对账入库 → 逐台记明细;
  2. pager.Pager 抽象让任务层与"云厂商怎么翻页"彻底解耦;VMware 走回调特例;
  3. 对账 = 先查后写(按云厂商 ID),provider 层零写库职责;
  4. mapping 归一化是"海关":方言进、普通话出;
  5. 管理属性(domain 等)来自凭证注入,不是云厂商给的;
  6. 单台失败只记账不中断整批;panic 有 recover 兜底但不重试。

5 自测题(对话式)

三道原书思考题,都是"真要在二开时动手改"的问题。先自己答,再看参考答案。

面试官
CreatTask 里任务先 go syncHost 后 insert(t),存在什么竞态窗口?如果要求"任务必须先落库、拿到确定的任务 ID 之后再启动 goroutine",代码要怎么调整?(提示:调整两行顺序就够了吗?insert 失败时 goroutine 怎么办?)
候选人
竞态窗口:go 语句只是"提交"了一个 goroutine,调度器不保证它晚于下一行 insert 执行。数据量小时同步毫秒级就跑完了,SyncTaskCallback(cb)会去更新一条还没插入的任务行——轻则更新落空,重则轮询方看到的状态顺序错乱(先见终态、后见"进行中")。

调整方案:把 s.insert(ctx, t) 提到 go 之前,且 insert 失败直接 return nil, err——此时 goroutine 还没启动,不存在"泄漏的孤儿任务";insert 成功后再 go,goroutine 里的 defer(recover + cb)机制不受影响,且 CreateTask 返回前任务已在库里,语义更稳。所以答案是:两行换位基本就够,但必须连带处理 insert 失败分支——只换顺序不管失败路径,等于留了一个"返回错误但后台照跑"的更大坑。顺带一提,syncHost 闭包持有的是 t 的指针,调整顺序不涉及引用问题。
面试官
syncHost 的 30 分钟超时 syncCtx 丢弃了 cancel。假设运营人员发现同步跑错了凭证想中途叫停,你能在现有架构上设计"任务取消"功能吗?需要改哪些地方?
候选人
思路就一句话:把丢掉的 cancel 捡回来,再开一个口子让别人能按到它。

① CreateTask 里改为 syncCtx, cancel := context.WithTimeout(...),把 cancel 存进 service 内部的 map(key 用任务 ID,注意并发安全用 sync.Map 或加锁);② 新增 CancelTask 接口——gRPC 和 HTTP 各注册一条路由(按第 5 章的路由注册模式,Metadata 权限点照配):校验任务状态是运行中,取出对应 cancel 调用,任务状态置为"已取消";③ syncHost 内部的所有阻塞点(pager.Scan 的 ctx、doSyncHost 的 ctx)都会因 ctx 取消而返回错误,defer 里判断 ctx.Err() == context.Canceled,把任务记为取消而非失败,再 cb(t) 落库;④ defer 里把 map 中的 cancel 删掉,防泄漏。

两个边界要讲清楚:已发往云厂商的在途请求撤不回来,取消的语义是"停止后续页/后续台";30 分钟超时与人工取消共用同一个 ctx,天然兼容不冲突。这是纯增量改动,不动现有同步主干——正好也补上了原书列出的健壮性缺口之一。
面试官
对账逻辑目前是"每台机器单独 DescribeHost + update/save",同步 10000 台就是 20000 次数据库往返。请设计一个批量优化方案(提示:先按 ID 批量查出已存在集合,再分"更新批"与"插入批"),并说明要注意的内存上限问题。
候选人
方案分三步:

① 批量查重:每页(或每 N 台,比如 500)收集本批 Meta.Id,一条 SELECT id FROM ... WHERE id IN (...) 拿到"已存在集合",取代逐台 DescribeHost;
② 分批写入:差集 = 插入批,走批量 INSERT(原生 SQL 多值 INSERT,或 GORM 的 CreateInBatches);交集 = 更新批——这里还能叠上第 4 章的 hash 增量同步:对比哈希过滤掉没变化的,剩下的再批量 UPDATE,或直接用 ON DUPLICATE KEY UPDATE 一条语句吃下插+更;
③ 批量记账:task_record 明细同样攒批 insert,别一台一条。

内存上限要重点防两处:切忌把 10000 台全攒在内存里拼一个巨型 IN——IN 列表过长会撑爆 SQL 长度和解析器,必须按固定批(如每 500 个 ID)分批查询;更新批如果逐台构造语句,要用有界的工作池控制并发,别把连接池打爆。收益量级:往返次数从"每台 2 次"降到"每批 3~4 次",万台规模从两万次往返压到百次量级——这也正是 pager 每页给我们天然分好批的原因,批处理不用自己攒。

6 本章小结

复习提示: - 同步五步是主动脉:建任务(前置检查五连)→ 取凭证+校验+解密 → 异步分页拉取 → 逐台对账入库(先查后写、按云厂商 ID)→ 逐台记 task_record 明细;provider 层零写库,管理属性靠 InjectBaseFromSecret 从凭证注入; - 三个健壮性缺口要烂熟于心:30 分钟硬超时且 cancel 被丢弃(无法中途叫停)、无重试机制、先 go 后 insert 的竞态窗口;单台失败只记账不中断整批,panic 有 recover 兜底; - 三条主动脉(同步/检索/凭证)走通之后,第 1 章架构图上的每根线你都能指到源码函数了——但这些缺口怎么补、标签为什么不落库、别名 bug 怎么修,第 8 章的二开方案会逐一给出答案。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!