OceanBase 的软件工程管理

OB 无论是从公司规模还是产品复杂度上来说,肯定算是数一数二的数据库大厂,比绝大多数云厂商里面的数据库团队都专业的多。所以其项目管理的经验,肯定还是有学习的价值。

这里浅浅记录一下 OB 是如何管理整个数据库项目的开发,不过我待得也不久,肯定总结的不全。

前置背景

Dima

Dima 是 OB 内部使用的类 Jira 平台,负责项目追踪管理。

OB flow

OB flow 是 OB 自己开发的一个流程管理软件,你所有的开发需求,都需要通过 ob flow 进行。

比如 QA 在 Dima 上面创建了一个【缺陷】,研发通过 ob flow start dima-id 命令开始开发。此时 Dima 平台上的【缺陷】,会追踪并记录你的整个开发生命周期。

Dima 平台上面的每个任务都有唯一的 dima-id。

当任务开发完成,并且已经合入目标分支,研发执行 ob flow finish 命令,它会删除本地工程,并同时把 Dima 上面的任务给自动 finish 了,自此 Dima 上的这个【缺陷】算是完成了整个开发周期。

管理理念

没有规矩,不成方圆,规则尽可能的通过系统进行约束,靠人为自觉遵守是不现实的。

一切东西都要留痕,在 OB 里面,无论是开发、修 bug、cherry-pick 等一切操作都会在 Dima 上留痕,可追溯。这么说吧,你不在 Dima 上面开个任务,你就没法写代码。

项目管理

制定发版规划

OB 会在每年开始的时候,就规划好今年要发布的版本,比如几号发布 x.x 版本,然后这个版本里面哪些需求是必保需求。

一个版本有提测日期和发版日期,每一个 feature 都需要研发在提测日期之前,发起提测。这些日期基本都是硬性 deadline,如果研发赶不上,就只能自己加班想办法赶上。

Feature 开发

OB 一个 feature 开发的流程如下:

  1. Leader 或产品在 Dima 上面创建 feature 需求,同时创建一个 feature 分支。
  2. 研发通过 ob flow start 开始开发,代码写完后找人 review,然后合入 feature 分支。
  3. Feature 开发完成后,研发开始走提测流程。提测流程里面需要完成如下事情:
    1. 跑大小 farm 回归,你可以认为这个 farm 里面会有非常非常多的测试 case,跑一轮需要很久。等跑完后,肯定会有一堆 case 挂了,需要研发自己确认是不是本次 feature 引入的。如果和本 feature 无关,研发就不用管。
    2. 跑性能回归,确保 TP、AP 性能没有回退。
    3. 编写提测文档,把上面的测试结果,和 feature 的介绍都写到提测文档里面。
    4. 在提测系统里面,提交提测文档,发起提测。接下来 QA 就会介入测试流程。
  4. QA 开始测试,测试完成后,QA 会出具测试报告,贴在提测系统里面并同意合入本 feature。
  5. 研发可以把 feature 分支合入主干分支。但是因为大家都是赶 deadline 的,所以此时主干代码很容易冲突,前脚合了别人的,后脚就和你的 pr 冲突了,这时候你就要自己反复解冲突。

这么做好处是确保了主干的相对稳定性,像开源社区那种,把一个 feature 拆成多个 pr 合入,很容易造成主干的不稳定,这样会导致发版的时候,QA 每一轮回归都会有新问题产生。

虽然 OB 这么搞也不能 100% 保证主干的绝对稳定,因为像 bugfix 这种,还是可以随时合的,所以最后一般还是通过锁分支大法来解决问题。

Feature 的 cherry-pick

当客户有需求时,一些主干上的 feature 会被 cherry-pick 到已发版的分支上,但是你不能直接合入,还是需要重新发起一次提测流程。

Bug 修复

当日常 QA 或者客户发现 bug 的时候,我们会在 Dima 上面创建一个【缺陷】,然后进行修复。在合入代码的时候,ob flow 会问你要不要 cherry-pick 到以前的分支。你选了 cherry-pick 到哪些分支,ob flow 就会在自动在 DIMA 上创建那几个要 cherry-pick 分支的 【缺陷】,这样 DIMA 上也记录了你的 cherry-pick 过程。

三方库引入,都需要进行审批

当你引入一个三方库,比如 paimon-cpp,你需要获得各个组的 Leader 审批。每个 Leader 都会做自己负责领域的检查,比如安全部门会审批 license 是否合规,会不会影响信创,有没有已知漏洞等等。你只有获得所有 Leader 的 approve 之后,才可以引入该三方库。整个审批过程都会被留痕。

值班

当客户遇到问题,都会记录在一个值班平台里面,然后研发会在那个平台里面对问题进行解决。所有问题的解决过程,都会在这个值班平台上面留痕。

这样以后有类似的问题时候,DBA 可以先从值班平台搜一下,看看有没有类似的问题。当然现在有 ai 了,值班平台沉淀的解决方案,结合 RAG,能大大提升 DBA 解决问题的效率。

代码管理

OB 的代码风格是很有特色的,反正我是没见过,算开眼了。当然这不是讽刺,这种代码写法很奇怪,但是你不得不说就是有用,有时候即使我代码有明显的 bug,都不会 core 掉。

OB 在 https://oceanbase.github.io/oceanbase/coding-convention/ 里面说明了一些代码风格规则。

所有函数的返回值都是 int

OB 的函数定义一般都是:

int func() {
    int ret = OB_SUCCESS;
    return ret;
}

返回值就是错误码,OB 会把错误码的定义都放到 https://github.com/oceanbase/oceanbase/blob/master/deps/oblib/src/lib/ob_errno.h

理论上每个模块都要搞个专属的错误码,不过很多时候大家都会偷懒,直接返回个 OB_ERR_UNEXPECTED 万金油错误码。因为貌似添加新的错误码,需要审批?

函数不能提早 return

int func() {
    int ret = OB_SUCCESS;
    // do something
    // do something
    // do something
    return ret;
}

你只能在函数的末尾执行 return,这么做是为了当函数长了,如果有人在中间提早 return 了,有可能导致函数末尾的资源释放操作没被执行。

这个我存疑,因为有时候为了刻意在末尾才能 return,反而导致函数的逻辑复杂起来。

int func() {
    int ret = OB_SUCCESS;
    if (OB_FAIL(do_something())) {
        ret = OB_ERR_UNEXPECTED;
    }

    if (OB_SUCC(ret)) {
        if (OB_FAIL(do_next_1())) {
            ret = OB_ERR_UNEXPECTED;
        }
    }

    if (OB_SUCC(ret)) {
        if (OB_FAIL(do_next_2())) {
            ret = OB_ERR_UNEXPECTED;
        }
    }

    return ret;
}

不能使用 break、continue、goto 这种影响代码执行流的东西

int func() {
    int ret = OB_SUCCESS;
    for (int i = 0; i < 10; i++) {
        if (OB_FAIL(do_something(i))) {
            // 如果这里执行失败,你不能写 break,立刻结束 for 循环
        }
    }
}

需要写成

int func() {
    int ret = OB_SUCCESS;
    for (int i = 0; OB_SUCC(ret) && i < 10; i++) {
        if (OB_FAIL(do_something(i))) {
            // 在 for 循环上面,通过 OB_SUCC(ret) 进行判断
        }
    }
}

我同样存疑,感觉没啥必要。

非必要不能使用 std 标准库

OB 需要严格控制内存,std 内部会自己调用 new 分配内存,不易于管控。所有的内存都要通过 Allocator 来获得。Allocator 上面会记录租户的 tenant_id ,这样就可以严格控制每个租户内存的分配。

从 Allocator 里面申请出来内存的时候,也需要进行判空处理,返回空指针就意味着这个租户内存耗尽,要返回内存不足的错误码。

Allocator 也可以记录模块 ID,比如 OB 可以在 Optimizer、Executor 上面使用不同的 Allocator,这样后续分析问题的时候, 可以很容易的发现哪个模块内存用的多,或有内存泄露。

这里详细描述了 OB 的内存管理:https://oceanbase.github.io/oceanbase/zh/memory/

对于 std::vector 这种常见的容器,OB 自己也都手写了,以便于和 OB 的 Allocator 配合使用,具体见 https://oceanbase.github.io/oceanbase/container/

对于和三方库对接的地方,还是能够使用 std 标准库的,毕竟你不可能去改三方库的源码。

不能使用智能指针

上面说了,非必要不能使用 std 标准库,所以智能指针也用不了。对于内存释放这种操作,OB 一般就是不管,他有一个 ArenaAllocator,你从这个 ArenaAllocator 申请完内存后,你不用管释放,后面在这个 ArenaAllocator 生命周期结束之后,其上面所有挂载的对象,会一次性统一释放。

一次性全部释放还更高效,比你一点一点 free 来的快多了。

可以多打日志

很多系统不推荐你频繁打日志,因为输出过多日志会影响系统的性能。但是在 OB 你不用担心这个,OB 的日志系统已经被优化过了,当日志输出流量很大时,OB 会自动对日志限流,确保系统运行的稳定。

日志多的好处就是,出问题的时候,很容易定位问题,你可以根据整个 WARNING 日志出现的顺序,知道整个调用栈。

int func1() {
    int ret = OB_SUCCESS;
    ret = OB_ERR_UNEXPECTED;
    return ret;
}

int func2() {
    int ret = OB_SUCCESS;
    if (OB_FAIL(func1())) {
        LOG_WARN("func1 failed", K(ret));
    }
    return ret;
}

int func3() {
    int ret = OB_SUCCESS;
    if (OB_FAIL(func2())) {
        LOG_WARN("func2 failed", K(ret));
    }
    return ret;
}

上面这个例子中,一旦 func1 执行错误,在每一层调用都会 LOG_WARN 一次,这样你从日志里面就能够看到整个错误调用链。

所有函数的返回值都要被判断

就是你每一个调用的函数,都要判断其返回值是否符合预期,不符合立刻打日志 LOG_WARN ,并修改错误码,一般大家都用 if (OB_FAIL(func())) 这种写法简化代码。

函数的每个入参都要检查

每个函数都要天然假设入参不安全,需要进行检查后才可以继续执行,比如:

int func(Metadata *metadata, int flag) {
    int ret = OB_SUCCESS;
    if (OB_UNLIKELY(metadata == NULL || flag <= 0)) {
        ret = OB_INVALID_ARGUMENT;
    } else {
        // do something
    }
    return ret;
}

下面这个例子函数入参是数组,我们要先检查 idx 下标访问是否会越界:

int func(ObIArray<int> data, int idx) {
    if (OB_UNLIKELY(idx < 0 || idx >= data.count())) {
        ret = OB_INVALID_ARGUMENT;
    } else {
        // do something
    }
    return ret;
}

Code owner 机制

每一个 CPP 文件都有 code owner 负责,如果你的代码想要被合入,需要该文件的 codeowner 给你 approve。

一般 code owner 离职了,leader 会让你改 code owner 匹配文件,换个人。

总结

OB 肯定做的不止我说的这些,奈何低 P 没有机会接触整个链路,只能管中窥豹。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注