疾在腠理,汤熨所及
扁鹊见蔡桓公,立有间。扁鹊曰:“君有疾在腠理,不治将恐深。”桓侯曰:“寡人无疾。”扁鹊出,桓侯曰:“医之好治不病以为功。”
居十日,扁鹊复见,曰:“君之病在肌肤,不治将益深。”桓侯不应,扁鹊出,桓侯又不悦。
居十日,扁鹊复见,曰:“君之病在肠胃,不治将益深。”桓侯又不应,扁鹊出,桓侯又不悦。
居十日,扁鹊望桓侯而还走,桓侯故使人问之。扁鹊曰:“疾在腠理,汤熨之所及也;在肌肤,针石之所及也;在肠胃,火齐之所及也;在骨髓,司命之所属,无奈何也。今在骨髓,臣是以无请也。”
居五日,桓公体痛,使人索扁鹊,已逃秦矣。桓侯遂死。
“扁鹊见蔡桓公“曾入选中学课本,当年的教材节选删去了原文后面的一句议论:
故良医之治病也,攻之于腠理。此皆争之于小者也。夫事之祸福亦有腠理之地,故曰:“圣人蚤从事焉。”
故而语文老师们在讲授这篇文章时,将其中心思想落脚在“人要正视缺点,切莫讳疾忌医”上。但实际上有些断章取义,作者的中心思想其实是借扁鹊阐述的医理来讲解做事的方法,即要争之于小、蚤(早)从事。
在互联网公司的开发工作中,80%甚至90%的程序员都是在做业务开发。尽管所应用的技术深度和难度未必比的上基础架构的研发,但随着业务的成熟,在纷繁复杂的框架结构与业务逻辑之上持续进行快速且又稳定的迭代也绝非易事。尤其是在已经度过了快速迭代,野蛮生长的阶段,进入互联网行业的下半场之后,如何实现快递迭代但又能保持持续稳定这一目标,是一个重要的课题。
在前司期间,我陆续负责了多个模块的工程开发,每天都有大量算法同学在我管辖的模块上进行策略迭代。随着日积月累的迭代,代码腐坏在所难免,并且其中有两个模块本身有就很重的历史包袱,作为工程OWNER,我个人除了一些业务需求开发外,也需要对模块的稳定性、性能问题以及算法同学的研发效率进行负责。经常面对各种core dump、内存泄露、耗时暴涨等问题实在是疲于奔命。对于代码的治理必须马上提上议程,彼时心中千头万绪却不知该从何做起。这时“疾在腠理,汤熨之所及也,今在骨髓,臣是以无请也。“的词句映入脑海。
从扁鹊这里延伸,不难理解经典的中医学说:防病于未萌,治病于初起。并且治不如防。这和“未雨绸缪”一词是类似的道理,更通俗一点的说法就是:
亡羊补牢不如防患于未然 。
“防病于未萌”是最理想的状态,即在没有风险没有暴露的时候就考虑到进行预防。不过就如同棋手落子之前的计算,通常也会百密一疏,在实际工作中则有可能百密十疏。所以“治病于初起”其实也未尝不可,即在风险初露的时候尽早拔除。留心观察,见招拆招,以不变应万变。思来想去个人感觉“防微杜渐”一词或许更为简明扼要,言简意赅。下面我都会尽量使用“防微杜渐”一词来给我心中方法论下定义。
总而言之就是风险越早发现越好。做到早发现,早治疗。因为越早发现,它造成的影响也越小,通常越早发现也意味着越好排查和解决。作为后台开发程序员,我个人总结了几句箴言:
能在编译时发现,不在开发时发现
能在开发时发现,不在测试时发现
能在测试时发现,不在上线时发现
能在服务启动时发现,不在请求处理时发现
中国人讲道与术,本文中我会介绍一些术,但是我需要大家明白,本文目的其实是让大家对本文所述之道有更深刻的认知。因为术是有局限性的,比如我是写C++的后台程序员,对于编程语言的部分,就不会阐述到Jave/Go的防治之术,但是我认为理解本文所述之道,对所有程序员都会有所帮助。另外术是没办法通过一篇文章穷尽的,记住只要心中有道,术会自生。
在编译时发现
为什么要在编译时发现,因为这是最靠前的阶段,如果能在编译期间发现问题,能大大的节省我们开发自测的时间。往小了说,一些疏忽大意引发的问题,很可能在测试的时候排查很久才能找到问题,浪费自己的时间,白白加班!往大了说,这种问题可能在测试阶段都测不出来,直接带到线上。引发风险。
先说一个亲身经历。
C++中比较相等时,右值应放前面
这其实是大学时,老师就讲过的一个好的代码习惯,即:
if (a == 1) {
}
应该写成:
if (1 == a) {
}
这样主要是避免手误,把 if (a == 1) 写成 if (a = 1),因为在C++中 if (a = 1)是合法的,能通过编译的,这个表达式的值为true,但 if (1 = a) 是不合法的语法,如果错把 == 写成 = 编译会失败。当然在Java中也是不合法的,可不遵守这一习惯。
尽管我早就知道右值放左边是一个好的习惯,但是却很少遵守它,感觉这样代码会不够顺手,a == 1 更符合左手到右的思维。就好像 if (vec.size() > 0) 比 if (!vec.empty()) 更顺手一样,因为后者经常会是先写vec.empty(),再把光标移动到前面补!。另外感觉自己不会犯错,写出 if (a = 1) 这样的代码。
但多年以前我在百度工作期间,我就曾写下了这样的代码。当时有某一个新策略只在部分请求中生效,而如何判断是否满足条件,是去检查一个int类型的变量是否为1。当时应该是需求比较匆忙,最后这行代码并没测试就上线了。没错。我写成了 = ,所以变成了全流量生效,因为是某个垂类业务,对大盘影响不大,自身对监控关注又不够,所以终于酿成事故。好在由于广告业务的特殊性,前期损失的收益在当日后面的时间可以补救回来,所以当日统计最终影响面并不大。但此次问题,我深感懊悔,因为这是一个十分低级的问题。
彼时回想起之前参加过的其他人的事故case study会议,经常听到的反思和改进是:仔细、仔细、再仔细。但此次经历让我深刻认识到靠人的仔细是靠不住的,所以我认为:
不能依靠仔细,要依靠工具!
我所说的工具并不只是说依靠软件或者Linux命令,也包括这种编程习惯。也许你会说,只要加强测试就可以了,是你测试不够,但是一方面测试也可能百密一疏,另外如果编译阶段能发现,那么解决起来肯定比测试时发现逻辑不符合预期后再去排查问题原因要更节省时间!
从此以后我再也不去写右值在左的代码,尽管有时候比较对象是常量并非变量。虽然a是常量的时候 if (a = 1)编译也会失败。但是好的编程习惯就是这样,就是只要你无脑遵守就好了,减少一些思维劳动去检查比较对象是不是常量,让自己变的机械有时候未必是坏事。
当然如果是 != 或者 > < 都不需要把右值放到左边。
值得一提是,基本数据类型遵守右值在前这个规则其实被遵守的概率很高,但其他类型的比较可能更容易被忽视。比如迭代器,比如:
auto it = exp_map.find(key);
if (it == exp.end()) {
...
}
应写作:
auto it = exp_map.find(key);
if (exp_map.end() == it) {
...
}
在第一种写法中,如果 == 如果写错成 =,也是能编译通过的。
还有哪些工具或者工具思维可以帮助我们避免风险呢?下面会介绍一些C++相关的“术”的部分。如果是其他编程语言的使用者也可以跳过。如前文所言,“术”有局限性,并且无法穷尽。
利用g++参数
对于C++项目而言,首先我们要学会利用g++的编译参数来帮我们规范风险,如果你使用其他编译器,应该也有类似设置。
-Werror的探讨
在条件允许的时候开启-Werror是最理想的,它不放过任何语法错误。但有时候不太现实,因为我们也会依赖到外部代码,开启-Werror导致外部库的代码编译不过,我们可能不能直接修改它们的代码。你可能会说你是以库(.a,.so)的形式依赖的,不会受-Werror的编译参数影响,但是你别忘记头文件,头文件是直接include到自己项目中并且参与编译的,头文件中有时候也是会有语法问题的,尽管可能不如源文件多。并且这对于header only的开源库来说可能是灾难,因为他们都只有头文件,没有源文件,大量逻辑写在头文件中。比如rapidjson、taskflow
关闭-fpermissive
g++编译问题千万条,-fpermissive 第一条!
作为C++程序员,第一职业素养就是不要开启-fpermissive编译参数,如果老项目已经有开启的时候,那么就要关闭!因为-fpermissive会放过很多不规范甚至不合法的C++语法。比如:
- const的变量被修改
- 函数传参和函数声明不一致
- 头文件中声明了函数默认值,源文件又也声明了默认(有默认值不一致的风险)
- 给中间的参数设置了默认值但是最后一个的函数参数没有默认值
- 类的static成员在源文件定义的时候也写了static(可能危害不大,但不规范)
- 其他
诸如此类的代码问题竟然都能编译通过,有时候你可能觉得对这些不规范语法零容忍仅仅是我个人的“代码洁癖”。其实不然,这些很多都潜藏隐患。另外在针对老项目做后续进一步的稳定性优化、性能优化以及研效提升的时候,语法问题都是要首先修复的,后续的工作才好开展。比如某一个变量,你作为常量引用传给了函数F,后续某次需要需要并行调用函数F两次,但其他入参不同。如果这个常量引入传入的变量在函数F中被修改了也能编译通过在线上运行了很久也没出问题,那么你当你误以为函数F不会修改这个变量,然后改成并行调用两次的时候,很可能会有coredump风险!
遵守代码的规范是维护了一个每个代码开发者的认知协议,不规范的代码破坏了这一协议,使得代码变成屎山,如果不能对老代码面面俱到则极易踩坑!就好比大家都认为靠右行驶,但是突然迎面出现一个逆行,酿成车祸。
所以当我在前司接手了一个老模块之后做的第一件事就是删掉了这个编译参数,然后不出意外收到了一堆编译错误……,在这些都改完之后,才开启的后续深度的优化之旅。
有些公司是mono repo的代码仓库管理模式,即很多服务/模块的代码不是在单独的git上管理,而是在同一个git上,通过不同的二三级目录来存储不同模块的代码。这时候整个项目可能会有统一的编译配置,比如整个项目都有一个bazel配置,会全局生效,当然每个子目录中的BUILD中可以添加单独的编译参数。此时如果全局配置是开启了-fpermissive的时候,我们可能是没有权限修改的,但这也有办法解决,那就是在我们自己的模块目录中的BUILD中加上-fno-permissive编译参数!
-Werror=return-type
这个可以防止函数在声明了返回值的时候,但函数内却漏了return的bug。如下代码:
int foo(Bar* bar) {
...
if (x) {
return 0;
}
}
上面代码明显有问题,如果if没命中,那么缺失其他的return。但默认情况下这个能编译通过。但是加了-Werror=return-type 之后就能让编译失败。从而减少bug。
-Werror=maybe-uninitialized
这个用来减少变量未初始化的bug。好的编码规范,都告诉我们基础数据类型的局部变量要做初始化,否则是默认值。但是同样,人的仔细是靠不住的,再有经验的程序员也有可能犯错。比如:
int foo(int a) {
int x;
if (a >= 10) {
x = 2;
} else if (a >= 0) {
x = 1;
}
...
这里的逻辑依赖x
}
将x的初始化放到了下面的条件分支中,但是如果条件分支遗漏了条件。比如上例中 a 未负数,那么x的值将是不确定的。而 -Werror=maybe-uninitialized 可以发现这类错误。
-Werror=type-limits
比较的数字超出int范围,if永远false
int doc_type = doc->doc_type();
...
if (doc_type == 3808059108 || doc_type == 3856151248) {
is_fresh_doc = true;
}
doc_type声明成了int类型,但是下面if中和doc_type比较是否相等的数字都超过了int范围,所以永远不可能为true。
其实在DocInstance的proto定义中,doc_type是uint32的,int doc_type = doc→doc_type(); 这段代码相当于强转了uint32的值,变成了int,导致这个 if 永远不会为true,逻辑不对。
现在可以在编译阶段编译失败,然后发现该错误。
-Werror=shadow
这个是防止变量的shadow,引发bug。比如:
class Config {
public:
void init(const std::string& filename);
string param;
};
void Config::init(const std::string& filename) {
string param;
... 解析配置文件,给 param 赋值。
}
在init函数内定义了一个和成员 param 同名的变量(也是手误),这个导致最终给局部变量param 赋值,并没有给 成员 param 赋值。但这并不会造成编译失败,从而引发bug。
加上 -Werror=shadow ,就能在编译期间报错。
利用C++语法
有一些看似平平无奇的语法,其实对于防范风险来说也是有奇效的。
override
先回顾一下C++的多态,父类函数用virtual关键字修饰(称之为虚函数),子类可以覆写(覆盖/重写)父类的虚函数,当使用父类指针调用该函数的时候,如果对象是子类对象,那么会自动调用子类的该函数,而不是父类的。但是有时候因为手误,可能导致并没有覆写父类虚函数。从而出现逻辑错误。
从C++11开始引入的override就能帮你在编译期间做这个校验,从而发现问题。
当然不加override也是能覆写父类函数的,这个override只是帮你做一下检查而已,然而据我的经验,override至少可以减少如下三种不仔细导致的问题。
第一种:参数列表不一致(比如类型,或参数个数写错)
// a.h
class A {
public:
virtual void foo(long x, int y);
};
// b.h
class B: public A {
void foo(int x, int y) override; // 编译失败
};
// c.h
class C: public A {
void foo(long x) override; // 编译失败
};
第二种:函数名不一致
// strategy_base.h
class StrategyBase {
public:
virtual void run_strategy(int x);
};
// video_strategy.h
class VideoStrategy: public StrategyBase {
void run_stratgy(int x) override; // 编译失败
};
在函数名很长的时候,子类的函数名容易写错,你以为覆写了父类虚函数,其实是自己新建了一个函数!
第三种:父类忘记加virtual
很多时候父类不是来自于标准库或第三方库,而是也是我们代码的一部分,那么漏写virtual也很场景(比较Java里就没有virtual关键字)
// a.h
class A {
public:
void foo(int x);
};
// b.h
class B: public A {
void foo(int x) override; // 编译失败
};
成员函数加上 const 声明
比较基础的C++语法,在不需要修改成员变量的成员函数上加上const声明。为了这样呢?很多人误以为是可读性,其实不尽然。假设有一个全局单利的管理类A:
class A {
public:
static A& inst() {
static A inst;
return inst;
}
// ...
bool hit(const std::string& key);
private:
A() {}
map<std::string, int> dict_;
};
在每个请求中需要判断是否命中,比如:
if (A::inst().hit("new_exp")) {
...
}
如果实现成这样:
bool A::hit(const std::string& key) {
return dict_[key] != 0;
}
会有问题,因为map的operator [] 在key不存在的时候,会自动在dict_中插入。但是在处理多个线程的过程中,并发的插入map会导致core dump。如果加上const就可以避免非预期的修改。
比如:
class A {
public:
static A& inst() {
static A inst;
return inst;
}
// ...
bool hit(const std::string& key) const;
private:
A() {}
map<std::string, int> dict_;
};
bool A::hit(const std::string& key) const {
return dict_[key] != 0;
}
会直接编译失败,这时候就会发现有并发修改map的风险,应改成:
bool A::hit(const std::string& key) const {
auto it = dict_.find(key);
if (dict_.end() == it) {
return false;
}
return it->second > 0;
}
考虑C++实现反射时的同名风险
C++原生是不支持反射的,但是通过宏和map可以实现通过字符串来创建对象,从而拿到模拟对象的功能。比如实现一个Node类的反射功能。
class Node;
...
class NodeFactory {
public:
static NodeFactory& instance() {
static NodeFactory inst;
return inst;
}
using create_node_fun_t = std::function<Node*()>;
void put(const std::string& name, create_node_fun_t&& fun) {
_node_create_fun_map.emplace(name, fun);
}
Node* create(const std::string& name) {
auto it = _node_create_fun_map.find(name);
if (it == _node_create_fun_map.end()) {
return nullptr;
}
auto&& create_node_fun = it->second;
return create_node_fun();
}
private:
std::unordered_map<std::string, create_node_fun_t> _node_create_fun_map;
};
#define REGISTE_NODE(class_name) \
struct RegisterNode##class_name { \
RegisterNode##class_name() { \
NodeFactory::instance().put(#class_name, [](){ \
auto node = new class_name; \
return node; \
}); \
} \
}; \
在定义了Node子类后,通过函数宏REGISTE_NODE进行注册。
// foo.h
class Foo: public Node {
public:
...
};
// foo.cpp
REGISTE_NODE(Foo)
...
然后通过字符串"Foo" 就创建出Foo的对象。 但是如果在项目庞大后,各种Node变多之后,可能在两个文件中存在同名的Node! 比如在两个文件中命名空间不同,但类名都叫Foo:
namespace A {
class Foo: public Node {
public:
...
};
} // namespace A
namespace B {
class Foo: public Node {
public:
...
};
} // namespace B
在各自的源文件中都使用REGISTE_NODE(Foo)进行注册。
但最终在NodeFactory的map中存放的“foo”指向是是哪个Foo类型其实是不确定的,这就会出现潜在的风险! 如何解决呢?这时候可以利用extern "C"来消除C++命名崩坏的功能来达到。重新实现REGISTE_NODE()这一函数宏。
#define REGISTE_NODE(class_name) \
struct RegisterNode##class_name { \
RegisterNode##class_name() { \
dipper::flow::NodeFactory::instance().put(#class_name, [](){ \
auto node = new class_name; \
return node; \
}); \
} \
}; \
extern "C" { \
int reg_node_##class_name; \
} \
在extern "C"中定义了一个全局变量,变量名包含REGISTE_NODE的参数class_name。这时候如果有在不同命名空间中出现了同名的类,进行了REGISTE_NODE注册,那么在编译的时候会因为出现了同名的全局变量而导致编译失败!这时候也就能在编译期间发现问题了!
其他C++语法
其他C++关键字,比如 static_assert 、 final 也能在编译期间实现一些检查,让不符合预期的使用方式,直接编译失败。
前面提到的方法,都是编译时发现。当然也并不是所有问题都能在编译期间发现。
在服务器启动时发现
当我们使用第公共组件的时候,一般都需要初始化。这期间如果遇到初始化失败一定要抛异常或者调用exit()让程序无法启动,从而在服务部署阶段就发现问题。而不是在请求开始处理的时候,还在使用初始化失败公共组件,这时候可能导致线上服务或者业务逻辑的种种非预期问题。
当我们对外提供组件库的时候,也一定要提供初始化的函数,并且明确返回是否初始化成功的状态。
另外有一些外部组件虽然有初始化函数,但是仅仅是做了一个简单的变量存储,并没有进行过多的初始化检查,这时候就需要我们自己来在自己服务的初始化阶段补齐相关的初始化能力。
假设有一个RPC client的管理类,维护了多个RPC的client。初始化的时候是传入了一个配置文件,配置文件中维护了一个name到服务地址的映射。它的初始化函数仅仅是做了读取文件解析配置,然后存储了映射关系。但是并没有检查里面的服务地址(命名服务或者ip:port)是不是正确,这时候就需要我们自己进行额外的检查,比如让每个client发起一次探测的rpc。因为确实可能存在服务的地址笔误填错的可能。
服务运行时
运维友好
无监控不系统
在开发阶段考虑到关键日志的输出、新增监控与报警。打印关键信息。
std::unique_ptr防止内存泄露
在C++代码中应该避免实现显式地使用new和delete。但是有时候代码中仍然可能无法避免。比如brpc的bthread_start_background()函数函数需要接收一个void*类型的指针用来传参,这时候一般是在外部使用new来创建一个参数对象,然后在回调函数中进行delete。
void* callback(void* arg) {
Args* args = (Args*)arg;
...
delete arg;
return nullptr;
}
void foo() {
Args * args = new Args;
// args中的成员赋值
bthread_start_background(callback, (void*)args);
}
但是如果callback中有多处return,很难保证能够在每次return之前都能进行delete。这时候就可以利用std::unique_ptr。
void* callback(void* arg) {
Args* args = (Args*)arg;
std::unique_ptr<Args> up(args);
...
if (...) {
return nullptr; // 漏了delete
}
return nullptr;
}
出问题方便排查
比如代码中使用noexcept关键字,当遇到C++异常导致的coredump的时候,通过更加精准的栈信息,来快速定位。详情可以阅读 《一剑破万法:noexcept与C++异常导致的coredump》
清单,对抗新旧需求之间风险
前面提到的经验和方法都是编程语言、后台服务设计方面比较纯粹技术。但实际工作中还有很多导致线上事故或者导致二次开发,工作返工的事情是在实现产品或策略需求的过程中,对需求分析不到位,或者遗漏了本次需求与历史需求的冲突点,导致边界情况无法自测到而导致的。
随着时间的推移,需求开发的越来越多。每次新的产品需求、策略需求都需要考虑到是否要同时修改一些已有的流程或逻辑。虽然也会做测试,但难免会遗漏一些冷门的情况。稍有遗漏就会造成新的需求在某些偶条件下不符合预期,有时候会引发线上大面积的用户体验case以及模块稳定性故障。
这时候该怎么办呢?其实没有高明的方法。我个人的经验是使用清单,也就是Checklists。
有一本书《清单革命》讲述了使用清单如何重塑了医疗、航空、建筑以及投资领域。
这本书中有一句话说的不错:
“无知之错”可被原谅,“无能之错”不被原谅。
这里的“无知之错”指的是因为自身技术、知识水平不够导致的错误,这种通常是能被原谅的。“无能之错”(感觉翻译的不够好)并不是因为技术、知识水平不够,而是因为马虎大意导致本不该犯错的地方犯了错,这种错误不能被原谅!
其实前面介绍的编译期、启动时、运行时的经验方法,也是在利用工具来避免“无能之错”。而对于需求,对于业务逻辑仅仅这些还有些欠缺。
受此启发,我认为也需要维护一个需求分析的清单。比如:
检查项 | |
XXX是否受影响 | |
YYY是否受影响 | |
是不是要AAA | |
是不是要BBB |
在接到一个产品需求或者策略需求之后,对照清单来逐一核对。当然清单不能又臭又长,需要简明扼要,不要妄图做到大而全,这样反而可能使得检查变得流于形式,变成无脑操作,更重要的是通过清单中的检查项建立一个需求分析的框架,在接到需求之后进行一系列思考分析,最终实际的检查和思考其实可能会超越清单本身覆盖的内容。
另外清单中的检查项也是要经常更新的,要加入新的或者删去旧的。并且也可以有多种不同的清单,应对不同类型的需求。
名不出于家!职场怪现象
魏文王问扁鹊曰:「子昆弟三人其孰最善为医?」扁鹊曰:「长兄最善,中兄次之,扁鹊最为下。」魏文侯曰:「可得闻邪?」扁鹊曰:「长兄於病视神,未有形而除之,故名不出於家。中兄治病,其在毫毛,故名不出於闾。若扁鹊者,鑱(chán)血脉,投毒药,副肌肤,闲而名出闻於诸侯。
魏文王曾询问扁鹊,他们兄弟三人谁的医术最高明。扁鹊回答说大哥医术最高,二哥其次,自己居末。但若论名气却正好相反,因为大哥在病人病情发作之前就加以防范,他的医术只被家人知道,鲜有人知。二哥在病情刚刚显露的时候进行治疗,在家乡内闻名。而自己治疗的疾病已经在病情末期,需要用手术开刀并且使用猛烈的药物来治疗,但也因此在闻名于诸侯。
这个故事和中医名著《黄帝内经》有共同之处,《黄帝内经》有云:
上医治未病 中医治欲病 下医治已病
即医术最高明的医生能够预防疾病的人。
且不讨论中医学说的科学性,也不纠结“医术高超”该如何定义。这则小故事在职场中却也常常被言中。比如A程序员善于防微杜渐,所负责的业务有很多风险都早早根除,减少了很多出现线上事故的风险。而B程序员并不精于此道,所辖业务事故不断。但久而久之在领导眼中却是另一番观感:A程序员负责的业务简单,缺乏挑战。若程序员A偶有事故会被抓住不放,时常拿来说事。领导只会看到半年出了一次事故,却不知道在日益庞大的系统规模下预防了多少个事故;而在领导眼中B程序员负责业务难度大,有挑战,事故发生时能做到熬夜通宵排查解决,如此负责任,有担当,不辞辛苦,兢兢业业,值得嘉奖。另外会对该业务重点关注,接着立专项、搞封闭,日会周会不断,略有改观便是莫大进步。
停下来想象一下,相信很多人也会有类似感受:在事故发生时出现的救火队长是“鑱血脉,投毒药,副肌肤“的英雄。却记不得有哪些是“汤熨疗疾”、“未有形而除之”的平凡人。
《孙子兵法》亦有云:
善战者无赫赫之功。