Skip to content
Go back

从零搭建 C++推荐服务框架:基于Blade构建

Edit page

Forrest Gump Fake Quote

Table of contents

Open Table of contents

项目背景

这是一套基于 brpc + DAG 图执行的 C++ 排序/推荐服务框架,结构上分三层:

目录角色
rank/框架层:vvframe DAG 引擎、各种 Manager(Graph/Cache/Transport/DynamicConf)、Impl RPC 基类
rank_services/业务层:业务 Node 子类、业务 proto、main/main.cpp 入口
rank/thirdparty/三方依赖:brpc、protobuf、gflags、glog、boost、tinyxml2 等,全部 vendored

构建系统用的是Blade。请求处理流程大致是:

client request
  → CommonImpl::process()
  → GraphManager::getGraph(name)
  → graph->run(context)        # DAG 节点按依赖顺序异步执行
  → response

DAG 拓扑由 conf/graph.xml 描述,节点名→类的映射由 conf/node.xml 决定,运行期 DynamicConfLoadMgr 每 2 秒轮询配置文件做热更新。

构建环境约束:必须 Linux x86_64

thirdparty/ 下所有 .a / .so 都是 x86_64 Linux ELF 二进制(brpc、jemalloc、leveldb没有 Mac 版本可替换

所以 Mac 上虽然能正常 IDE 阅读、编辑,但编译/链接/运行必须在 Linux 环境。三种典型方案:

我选第一种,用 OrbStack 起 Ubuntu 22.04。

容器准备

# Mac 上启动一个长期容器,挂载源码目录
docker run -d --name ranking-build \
  --ulimit core=-1 \
  -v /Users/52coder/github/ranking:/workspace \
  -v /Users/52coder/cores:/data/corefile \
  ubuntu:22.04 sleep infinity

docker exec -it ranking-build bash

容器内安装工具链:

apt-get update
apt-get install -y \
  build-essential gcc-11 g++-11 \
  python3 python3-pip ninja-build \
  patchelf zip git git-lfs vim
update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100
update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-11 100
git lfs install

Blade 是 Python 写的,clone 后加进 PATH:

cd /opt && git clone https://github.com/chen3feng/blade-build.git
ln -s /opt/blade-build/blade /usr/local/bin/blade

拉代码:注意 Git LFS

thirdparty/**/*.a*.so 都是用 LFS 存的(见 .gitattributes)。如果先 git clone 再装 git-lfs,那次 clone 拉到的全是 pointer 文件,链接器会报:

/usr/bin/ld: rank/thirdparty/tinyxml2/lib64/libtinyxml2.a: file format not recognized; treating as linker script
/usr/bin/ld: rank/thirdparty/tinyxml2/lib64/libtinyxml2.a:1: syntax error

第 1 行是因为 .a 实际内容是:

version https://git-lfs.github.com/spec/v1
oid sha256:abc...
size 12345

链接器把这段文本当作 linker script 解析就炸了。

修复

apt install -y git-lfs && git lfs install
cd /workspace
git lfs pull
file rank/thirdparty/tinyxml2/lib64/libtinyxml2.a
# 应输出: current ar archive

正确顺序永远是:git lfs installgit clone

五大编译错误根因

切到 Ubuntu 22.04 后,blade build //rank:framework 报了 50+ 条编译错误,但根因只有 5 类。

#1 protobuf 版本不匹配(最关键)

错误现象:

options.pb.h: This file was generated by a newer version of protoc
generated_message_bases.h: No such file or directory
PROTOBUF_NAMESPACE_OPEN does not name a type
ConnectionType / ProtocolType / CompressType not declared
tls_stringmap_temp was not declared

排查:

grep "GOOGLE_PROTOBUF_VERSION " thirdparty/protobuf/include/google/protobuf/stubs/common.h
# #define GOOGLE_PROTOBUF_VERSION 3007001       ← 3.7.1
grep "PROTOBUF_VERSION" thirdparty/brpc/include/brpc/options.pb.h
# #if PROTOBUF_VERSION < 3021000                 ← 3.21.x

vendored 的 protobuf 是 3.7.1,但 brpc 头文件由 3.21.1 的 protoc 生成 —— 80% 以上的错误都是这一条 cascade 出来的。

修复 —— 容器内重编 protobuf 3.21.12 覆盖:

cd /tmp
curl -L -o pb.tar.gz https://github.com/protocolbuffers/protobuf/releases/download/v21.12/protobuf-cpp-3.21.12.tar.gz
tar xf pb.tar.gz && cd protobuf-3.21.12

./configure --prefix=/workspace/rank/thirdparty/protobuf \
  CXXFLAGS="-fPIC -O2 -D_GLIBCXX_USE_CXX11_ABI=0"
make -j$(nproc)

# 备份旧版本
mv /workspace/rank/thirdparty/protobuf/include       .../include.bak3.7
mv /workspace/rank/thirdparty/protobuf/lib64_release .../lib64_release.bak3.7
mv /workspace/rank/thirdparty/protobuf/bin/protoc    .../bin/protoc.bak3.7

make install
mv /workspace/rank/thirdparty/protobuf/lib /workspace/rank/thirdparty/protobuf/lib64_release

-D_GLIBCXX_USE_CXX11_ABI=0 必须加 —— 要和其他 vendored .a 的 ABI 保持一致(BLADE_ROOT 里全局设置的是 =1,但我们这套 vendored 库实际编译时是 =0,混用会出现 std::string 链接失败)。

#2 glog 0.6+ 强制 C++14

'exchange' is not a member of 'std'
'make_unique' is not a member of 'std'

std::exchange 是 C++14、std::make_unique 也是 C++14。BLADE_ROOT 里默认是 -std=c++11

cxxflags = [
    ...
    '-std=c++11',     # 改成 -std=c++14
    ...
]

#3 bthread_attr_t 传值/传指针

cannot convert 'const bthread_attr_t' to 'const bthread_attr_t*'

node.cpp:69

// 错的
bthread_start_background(&tid, BTHREAD_ATTR_SMALL, b_func, args);
// 对的
bthread_start_background(&tid, &BTHREAD_ATTR_SMALL, b_func, args);

brpc 里 BTHREAD_ATTR_SMALLstatic const bthread_attr_t 值,但函数签名要的是指针。

运行期问题

问题 1:Server 起来了,curl 卡死

netstat 显示监听正常,但 curl localhost:16998 无响应、连接 CLOSE_WAIT

排查:

ps -T -p $(pgrep rank_server)   # 只有 2 个线程

正常 brpc 服务有几十个 bthread worker。原因是 impl.cpp 里用了:

m_server.Start(...);
daemon(1, 1);                    // ← 这里炸

daemon() 内部 fork() 了一次,但 fork 后 brpc 的 bthread 都丢了(bthread 是用户态调度,挂在原 pthread 上,fork 只复制调用线程)。

修复:删掉 daemon(),换成阻塞等待信号:

m_server.Start(...);
m_server.RunUntilAskedToQuit();

问题 2:protoc 找不到 .so

protoc: error while loading shared libraries: libprotoc.so.32: cannot open shared object file

readelf -d 看 RUNPATH:

RUNPATH /workspace/thirdparty/protobuf/lib

老编译机的绝对路径被烧进了二进制。修复

patchelf --set-rpath '$ORIGIN/../lib64_release' \
  /workspace/rank/thirdparty/protobuf/bin/protoc

$ORIGIN 是 ELF 的特殊变量,指二进制所在目录,这样无论包搬到哪都能找到 lib64_release/

问题 3:boost/core 子目录消失

新机重新 clone 后:

boost/core/ref.hpp: No such file or directory

但 GitHub 上文件明明在。原因:之前 .gitignore 里写了 core这是 basename 匹配,会匹配到任意层级里叫 core 的目录或文件,于是 boost/include/boost/core/ 这个软链被静默忽略了。

修复 —— .gitignore 里写锚定路径

# 错的(basename 匹配)
core
core.*

# 对的(仅匹配仓库根的 /core 和 /core.*)
/core
/core.*

举一反三:build 也是常见的有歧义名字,最好都加 / 前缀锚定。

部署打包脚本

把二进制 + conf + 必要 .so + 启动脚本打成一个开箱即用的 zip:

#!/bin/bash
# tools/build_package.sh
set -euo pipefail

CONF_SRC="${1:?usage: $0 <conf_dir> [version]}"
VERSION="${2:-$(date +%Y%m%d_%H%M%S)}"

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BIN="$ROOT/build64_release/main/rank_server"
PKG="rank_server-$VERSION"
STAGE="$ROOT/dist/$PKG"

[[ -x "$BIN" ]]            || { echo "missing binary: $BIN"; exit 1; }
[[ -d "$ROOT/$CONF_SRC" ]] || { echo "missing conf:   $ROOT/$CONF_SRC"; exit 1; }

rm -rf "$STAGE"
mkdir -p "$STAGE"/{bin,conf,lib64_release,log}

cp "$BIN" "$STAGE/bin/"
cp -r "$ROOT/$CONF_SRC"/. "$STAGE/conf/"
install -m 0755 "$ROOT/tools/start.sh" "$ROOT/tools/stop.sh" "$STAGE/bin/"

# 打包 .so,剔除 glibc 系
ldd "$BIN" | awk '/=>/ && $3 ~ /^\// {print $3}' \
  | grep -Ev '/(ld-linux|libc|libm|libdl|libpthread|librt|libgcc_s|libstdc\+\+|libgomp)\.so' \
  | xargs -I{} cp -L {} "$STAGE/lib64_release/"

patchelf --set-rpath '$ORIGIN/../lib64_release' "$STAGE/bin/rank_server"

cd "$ROOT/dist" && rm -f "$PKG.zip" && zip -qr "$PKG.zip" "$PKG"
echo "OK: $ROOT/dist/$PKG.zip ($(du -sh "$PKG.zip" | cut -f1))"

几个关键决策:

  1. 三方库 .a 都已静态链进 82M 的二进制,运行期只剩 libssl.so.3 / libcrypto.so.3 / libz.so.1 + glibc 系。前三个跨发行版可能版本不一致 → 打包;后者必须用目标机自己的 → 黑名单剔除。

  2. -L 解软链libssl.so.3 通常是软链到 libssl.so.3.0.x,不解软链的话 zip 里只有空软链,部署解压后链接断掉。

  3. RPATH 用 $ORIGIN:包解到任意路径都能跑,不用配 LD_LIBRARY_PATH

  4. conf 目录作为参数传入bash build_package.sh conf/demo —— 将来加 conf/prodconf/canary 不用改脚本。

部署机:

unzip rank_server-v1.0.zip
cd rank_server-v1.0
./bin/start.sh

框架内部的 3 个值得注意的设计点

工程跑通之后回头看代码,DAG 调度部分有几处实现挺有意思 —— 既看得出当年的取舍,也能识别出潜在风险。

Node::skip() — 双重否定的认知负担

Node 是 DAG 节点的基类,框架在执行某个节点时,会先调用其 skip() 决定是否真正执行业务逻辑:

void Node::run(std::shared_ptr<GraphContext> context) {
    if (!skip(context)) {
        do_service(context);
        if (type() == "cpu") run_output_nodes_if_ready(context);
    } else {
        run_output_nodes_if_ready(context);   // 跳过业务,但仍要触发后继节点
    }
}

bool Node::skip(std::shared_ptr<GraphContext> context) {
    return false;       // 默认不跳过 → do_service 总是执行
}

设计上是想让子类按需重写 skip() 返回 true 来短路某些条件下的执行(比如灰度开关、缓存命中等)。但调用点是 if (!skip())双重否定

“如果不跳过就执行” ←→ 子类覆盖时要思考”我什么时候返回 true 让自己不执行”

这种 API 命名在维护期会反复让人卡壳。更直观的写法是把语义正向化,例如:

virtual bool should_run(...) { return true; }   // 默认执行;子类按需返回 false
// run() 内: if (should_run(ctx)) do_service(ctx);

历史上这段代码还出过更严重的 bug —— 早期版本是 if (skip()) 直接进入 do_service 分支,与函数名完全相反。这种 bug 在静态分析里不会报,单测如果只覆盖默认路径(skip()=false)也发现不了,只有重写 skip() 期望短路的子类才会触发。教训:布尔值 API 的命名一定要正向化,“skip”、“disable”、“hide” 这种否定语义在调用点配合 ! 极易出错

CacheWrappervolatile 假装的”原子双缓冲”

热更新场景常用双缓冲:写线程把新数据写到备用 buffer,再原子切换索引;读线程拿到的要么是新数据要么是旧数据,绝不会读到半更新状态。代码里这套实现长这样:

template <class T>
class CacheWrapper {
protected:
    volatile unsigned int m_index;
    T m_data[2];

public:
    T& getData() {
        return m_data[m_index % 2];                    // 读
    }

    void updateData(T& data) {
        int tmp = m_index + 1;
        m_data[tmp % 2] = data;                         // 1) 写备用槽
        ++m_index;                                       // 2) 切换索引
    }
};

看起来像是无锁双缓冲,但实际上有两个并发缺陷

  1. volatile 不等于原子。C++ 里 volatile 只承诺”编译器不要把读写优化掉”,不保证多线程可见性、不保证读写不可分割、也不提供任何内存序。在 ARM、Power 这类弱一致内存模型上,读线程完全可能先看到 ++m_index 的结果、再看到旧的 m_data[tmp%2](写顺序被 reorder),结果取出半写完的数据。

  2. 写-写竞争。多个写线程同时调 updateData(),两次 m_index + 1 算到同一个槽,互相覆盖;++m_index 也不是原子的(读-改-写三步),最后 m_index 可能比预期少 1。

正确做法是用 std::atomic<unsigned int> 配合 release/acquire 语义:

std::atomic<unsigned int> m_index{0};

T getData() const {
    return m_data[m_index.load(std::memory_order_acquire) % 2];
}

void updateData(const T& data) {
    unsigned cur = m_index.load(std::memory_order_relaxed);
    m_data[(cur + 1) % 2] = data;
    m_index.store(cur + 1, std::memory_order_release);   // release: 保证 m_data 写在前
}
// 多写场景再加一把锁,或者用 CAS

memory_order_release / acquire 确保读线程在看到新 m_index 时一定能看到对应的 m_data 写入。这点 volatile 做不到。

线上实测:“偶发空指针 / map 访问越界” 这类不可复现的崩溃大概率就来自这种”看起来无锁、实际无序”的代码。

DynamicConfLoadMgr — mtime 轮询 + 2 秒延迟

配置热更新的实现非常朴素:

void DynamicConfLoadMgr::run() {
    while (m_state) {
        sleep(m_interval);                       // 默认 2 秒
        // ...遍历每个注册的 Conf 元素
        for (auto* e : m_elementVec) {
            e->refreshConfig();
        }
    }
}

bool ConfElement::refreshConfig() {
    struct stat st;
    stat(m_path.c_str(), &st);
    time_t now = time(nullptr);
    if (st.st_mtime > m_fileTime &&
        now > st.st_mtime + 2) {                 // 等 2 秒,确保写入完成
        m_fileTime = st.st_mtime;
        return parseConfig();
    }
    return false;
}

设计要点:

更稳妥的实现方向:

  1. inotify_add_watch(IN_CLOSE_WRITE | IN_MOVED_TO),事件驱动取代轮询 —— IN_CLOSE_WRITE 天然规避”写到一半被读”的问题
  2. 配合一个 staging file pattern:写入方先写 xxx.tmprename()xxx.xml(rename 是原子的)
  3. parseConfig 出错时保留旧配置而不是替换成空,避免因为一次坏配置把服务搞挂

另外这段代码里还有个隐蔽的 bug:

boost::unique_lock<boost::mutex>(m_mutex);    // ← 临时对象,下一行就析构了

这是一个未命名的临时对象,构造完立即析构,锁进了又立刻出来。等价于没加锁。正确写法是给 lock 起个名字:

boost::unique_lock<boost::mutex> lock(m_mutex);

C++ 里这类”幽灵锁”是面试常考也是线上常见的真实事故。

总结

整套链路的踩坑可以归到三个层次:

  1. 依赖版本管理:vendored 的三方库随时间漂移,protobuf 3.7 vs brpc 头(3.21)是典型不一致;C++ 标准也是 —— glog 0.6+ 已不兼容 C++11
  2. 构建/分发产物:Git LFS、ELF RPATH、动态库版本(libssl 3 vs 1.1)、glibc ABI —— 跨机器部署时全要考虑
  3. 运行期约束:daemon/fork 与用户态线程的冲突、容器里 core dump 需要进宿主 namespace —— 这些都是开发期容易忽略、上线时才暴露的问题

框架代码本身的设计也踩过几类典型坑:API 命名的双重否定、volatile 当原子用、未命名锁、轮询 + 朴素延迟做热更新。这些大都是”能跑、压测也过”,但在线上长尾流量、多写并发、跨架构部署时才暴露 —— 写无锁/热更新代码时务必把内存序、原子性、写入完成判定逐一想清楚。


Edit page
Share this post on:

Previous Post
Claude Code 源码架构深度解析
Next Post
Ghostty + Yazi 现代终端配置指南