
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 环境。三种典型方案:
- Docker / OrbStack 起 Ubuntu 容器
- SSH 到远程 Linux 主机(VS Code Remote-SSH 体验不错)
- 公司内置 CI / 远程 build host
我选第一种,用 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 install 再 git 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_SMALL 是 static 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))"
几个关键决策:
-
三方库 .a 都已静态链进 82M 的二进制,运行期只剩
libssl.so.3/libcrypto.so.3/libz.so.1+ glibc 系。前三个跨发行版可能版本不一致 → 打包;后者必须用目标机自己的 → 黑名单剔除。 -
-L解软链:libssl.so.3通常是软链到libssl.so.3.0.x,不解软链的话 zip 里只有空软链,部署解压后链接断掉。 -
RPATH 用
$ORIGIN:包解到任意路径都能跑,不用配LD_LIBRARY_PATH。 -
conf 目录作为参数传入:
bash build_package.sh conf/demo—— 将来加conf/prod、conf/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” 这种否定语义在调用点配合 ! 极易出错。
CacheWrapper — volatile 假装的”原子双缓冲”
volatile 假装的”原子双缓冲”热更新场景常用双缓冲:写线程把新数据写到备用 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) 切换索引
}
};
看起来像是无锁双缓冲,但实际上有两个并发缺陷:
-
volatile不等于原子。C++ 里volatile只承诺”编译器不要把读写优化掉”,不保证多线程可见性、不保证读写不可分割、也不提供任何内存序。在 ARM、Power 这类弱一致内存模型上,读线程完全可能先看到++m_index的结果、再看到旧的m_data[tmp%2](写顺序被 reorder),结果取出半写完的数据。 -
写-写竞争。多个写线程同时调
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;
}
设计要点:
- 基于
st_mtime的轮询,不依赖inotify/fanotify这类内核事件 —— 跨平台、易理解,但延迟取决于轮询周期(这里 2 秒) - “now > mtime + 2” 是一个朴素的”写入完成”判定 —— 配置文件被
vim/scp落地时通常是分多次write(),立即 stat 拿到的可能是不完整文件。等 2 秒能粗略避开这种情况,但不是绝对安全(大文件 / 慢盘可能写超过 2 秒) - 注册的
ConfElement子类(GraphConf / NodeConf / ServiceConf / LogLevelConf)各自实现parseConfig()调用对应 manager 的init()—— 重新加载时是整体替换,没做增量 diff;如果某个 manager 内部不是双缓冲实现,热更新瞬间会有短暂”配置撕裂”窗口
更稳妥的实现方向:
- 用
inotify_add_watch(IN_CLOSE_WRITE | IN_MOVED_TO),事件驱动取代轮询 ——IN_CLOSE_WRITE天然规避”写到一半被读”的问题 - 配合一个 staging file pattern:写入方先写
xxx.tmp再rename()成xxx.xml(rename 是原子的) - parseConfig 出错时保留旧配置而不是替换成空,避免因为一次坏配置把服务搞挂
另外这段代码里还有个隐蔽的 bug:
boost::unique_lock<boost::mutex>(m_mutex); // ← 临时对象,下一行就析构了
这是一个未命名的临时对象,构造完立即析构,锁进了又立刻出来。等价于没加锁。正确写法是给 lock 起个名字:
boost::unique_lock<boost::mutex> lock(m_mutex);
C++ 里这类”幽灵锁”是面试常考也是线上常见的真实事故。
总结
整套链路的踩坑可以归到三个层次:
- 依赖版本管理:vendored 的三方库随时间漂移,protobuf 3.7 vs brpc 头(3.21)是典型不一致;C++ 标准也是 —— glog 0.6+ 已不兼容 C++11
- 构建/分发产物:Git LFS、ELF RPATH、动态库版本(libssl 3 vs 1.1)、glibc ABI —— 跨机器部署时全要考虑
- 运行期约束:daemon/fork 与用户态线程的冲突、容器里 core dump 需要进宿主 namespace —— 这些都是开发期容易忽略、上线时才暴露的问题
框架代码本身的设计也踩过几类典型坑:API 命名的双重否定、volatile 当原子用、未命名锁、轮询 + 朴素延迟做热更新。这些大都是”能跑、压测也过”,但在线上长尾流量、多写并发、跨架构部署时才暴露 —— 写无锁/热更新代码时务必把内存序、原子性、写入完成判定逐一想清楚。