最近有一件事情让我印象特别深刻,作为引子和大家唠一唠:我们在内部做一些极端的流量回归仿真实验时,在 TiKV(TiDB 的分布式存储组件)上观测到了异常的 CPU 使用率,但是从我们的 Grafana Metrics、日志输出里面并没有看到异常,因此也一度困惑了好几天,最后靠一位老司机盲猜并结合 profiling 才找到真凶,真凶出现在谁都没有想到的地方:Debug 用的日志模块(澄清一下:目前这个 Bug 已经修复了,而且这个 Bug 的触发是在非常极端压力的场景下+日志级别全开才会出现,请各位用户放心)。
这篇文章并不是做 Bug 分析,我觉得更重要的是,找问题过程中我们使用的工具、老司机的思考过程。作为一个观察者,我看到年轻的同事看着老司机熟练地操作 perf 和在各种各样工具和界面中切换那种仰慕的眼神,我隐约觉得事情有点不对:这意味着这门手艺不能复制。
事后,我做了一些关于基础软件用户体验的调研,发现该领域的理论和资料确实挺少(大多数是 ToC 产品的研究,系统软件相关的大概只有 UNIX 哲学流派),而且缺乏系统化,依赖于作者个人「品味」,但是软件体验的好和坏显然存在,例如一个有经验的工程师看到一个命令行工具,敲几下就知道是否好用,是不是一个有「品味」的工具。
很多时候「品味」之所以被称为「品味」,就是因为说不清道不明,这固然是软件开发艺术性的一种体现,但是这也意味着它不可复制,不易被习得。我觉得这也不好,今天这篇以及可能接下来的几篇文章(虽然后几篇我还不知道写啥,但是先立个 Flag)会试着总结一下好的基础软件体验到底从哪里来。
作为第一篇,本文将围绕可观测性和可交互性两个比较重要的话题来谈。至于为什么把这两点放在一起聊,我先卖个关子,最后说。
可观测性
可观测性是什么?这可从我两年前发表的《我眼中的分布式系统可观测性》[1]一文中可见一斑,相同的内容我在这里就不赘述。随着在 TiDB 中对可观测性实践的深入,对这个话题有了更深的理解,为了更好的理解,我们首先先明确一个问题:当我们在聊可观测的时候,到底是谁在观测?
是谁在观测?
很多朋友可能会一愣,心想:这还用说,肯定是人,总不能是机器。没错,的确是人在观测,但就是这么一个浅显的道理往往会被软件设计者忽略,所以这两者的区别到底是什么?为什么强调人这个主体很重要?
要回答这个问题,需要清楚一个现实:人的短期工作记忆是很有限的。大量的心理学研究表明,人类工作记忆的容量大致只有 4,即在短期同时关注 4 项信息 [2],再多的信息就要靠分模块的方式记忆,如我们快速记忆电话号码的方式,以 13800001111 为例,我们通常不是一个个数字背,而是形如:138-0000-1111 进行分组。
在了解人的心智模型的一些基础假设和带宽后,我想很多系统软件开发者大概不再会炫耀:我的软件有 1000 多个监控项!这不仅不是好事,反而让更多的信息破坏了短期记忆的形成,引入了更多的噪音,让使用者在信息的海洋里花很多时间找关键信息,以及不自觉的分类(我相信大脑的一个不自觉的后台任务就是对信息建索引和分类,注意这同样是消耗带宽的),所以第一个结论:软件应用一屏的界面里面最好只有 4 个关键信息。那么,接下来的一个问题是:哪些是关键信息?什么是噪音?
区分关键信息和噪音
这个问题没有标准答案。对于系统软件来说,我的经验是:跟着关键资源走。软件其实很简单,本质就是对硬件资源的使用和分配,讲究平衡的艺术。关键的硬件资源无非也就下面几个,对于下面每一个关键资源在某个采样时间段(单点没有太多意义),都可以通过一些简单的问题的询问,得到对系统运行状态的大致图景:
- CPU:哪些线程在工作?这些线程都在干嘛?这些线程各自消耗了多少 CPU Time?
- 内存:当前内存中存储了哪些东西?这些东西的命中率情况?(通常我们更关注业务缓存)?
- 网络 I/O:QPS/TPS 有异常吗?当前主要的网络 I/O 是由什么请求发起的?带宽还够吗?请求延迟?长链接还是短链接(衡量 syscall 的开销)?
- 磁盘 I/O:磁盘在读写文件吗?读写哪些文件?大多数的读写是什么 Pattern?吞吐多大?一次 I/O 延迟多大?
- 关键日志:不是所有日志都有用,只有包含特定关键字的日志,人们才会关心。所以,有没有特定关键字的日志出现?
通过以上标准问题的灵魂拷问,必定可以对系统运行状态有一定的了解。
- 更进一步的关键是,这些系统的指标一定要和业务上下文联系在一起才能好用,举例说明,对于一个支持事务的数据库来说,假设我们看到 CPU 线程和 call stack,发现大量的 CPU 时间花在了 wait / sleep / idle 之类的事情上,同时也没有其他 I/O 资源瓶颈,此时,如果只看这些的数字可能会一脸懵,但是结合事务的冲突率来看可能柳岸花明,甚至能直接给出这些 lock 的等待时间都花在了哪些事务,甚至哪些行的冲突上,这对观测者是更有用的信息。
也并不是说其他的信息就没用,而是相当多的信息的价值是后验的,例如:绝大多数的 debug 日志,或者那些为了证实猜想的辅助信息,其实在解决未知问题时候几乎没有帮助,而且还需要观察者有大量的背景知识,这类信息最好的呈现方式还是折叠起来,眼不见为净的好。
如果打开 TiDB 的内部 Grafana 就会看到大量这样的指标,如 stall-conditions-changed-of-each-cf(虽然我知道这个指标的含义,但是我猜 TiDB 的用户里 99% 的人不知道),而且从名字里面我看到了写下这个名字的工程师内心的挣扎,他一定很想让其他人(或者自己)看懂这个名字指的是什么,但是比较遗憾,至少在我这里没有成功。
观察的下一步是什么?作出行动。
在做出行动之前想想,有行动的前提是什么?我们处理问题的行动大致会遵循下面模式(我自己总结的,但任何一本认知心理学的书都会有类似的概念):观察—>发现动机—>猜想—>验证猜想—>形成计划—>行动,然后再回到观察,反复循环。
这个里面人(或者是老司机的经验)体现比较重要地方是在从观察到猜想这个环节,至于观察的动机而言无非有两种:
- 解决眼前的故障;
- 规避潜在的风险(避免未来的故障)。
假设系统没有问题,也不太需要做出改变。 我觉得这两步之所以重要,是因为基本上其他环节都可以用自动化,唯独这两步很难,因为需要用到:人的知识/经验和直觉。
对于一个拥有好的可观测性的系统,通常都是能很好利用人直觉的高手,举个小的例子:当打开一个系统后台界面时,我们试着不去关注具体的文字信息,如果界面中的红色黄色的色块比较多,我们的直觉会告诉自己这个系统可能处于不太健康的状态,更进一步如果红色和黄色大致都聚集在屏幕的某个具体位置上,我们的注意力一定会聚焦到这个位置;如果一个界面上全是绿色,那应该是比较健康的状态。
怎么最大化利用人的直觉?或者说要引导到什么地方?我认为最好的点是:风险的预判。
人的直觉用在哪?风险的预判
此处需要利用一些先验知识。在聊这个话题之前,我想分享一个我之前听过的小故事,当年福特工厂里有个电机坏了,然后找了个老师傅,他听了听声音,看了看机器运转情况,最后用粉笔在电机上画了一条线,说这个地方的线圈多绕了多少多少圈,将信将疑的工人们照做,果然问题解决了,然后老师傅开了个 1 万美元的维修费(当时算是天价),福特的老板问他凭啥画一条线就收那么多钱,老师傅开了个账单:画线 1 美元,知道在哪画这条线 9999 美元。
故事的真假暂且不聊,假设是真的,我们可以看到直觉和经验,真的是能产生很多的价值,我当时听到这个故事的第一反应是,这个老师傅肯定这种情况见的多了(废话),而且这个问题一定是常见问题。
其实解决问题最难部分是通过观察(尤其是一些特征点)排除掉绝大多数不靠谱的方向,另外要相信常见故障的原因是会收敛的。这时一个具有良好可观测性系统的第一步就是能给使用者的直觉指引方向,这个方向就需要前人的知识来给出可能性最大的故障点以及相关的指标(例如 CPU 使用率等);第二步就是通过一些心理学小技巧把它展现出来。
下面以 TiDB 中即将会引入的一个小功能 TopSQL 加以佐证。这个功能说起来也很简单,我们发现很多用户故障都和少量的 SQL 相关,这类的 SQL 的特征是拥有和别的 SQL 有明显不同的 CPU footprint,但是每一条 SQL 的 footprint 独立看起来还挺正常的,所以 TopSQL 的功能就是回答:CPU 到底消耗了多少?在哪些 SQL 上?我试着不去解读下面这个截图,我猜聪明的你马上就能知道怎么用:
你的直觉会告诉你,后半段那段密集的绿色占比好像和其他有什么不一样,将整体的 CPU 使用率推高了,感觉有问题的样子,没错,这大概就是正确的方向,好的可视化能够利用人的直觉快速定位主要矛盾。
什么叫做“一个操作”?识别操作的真正的生命周期
刚才写第一点的时候想到还有一个经常被人忽略的关键资源:时间。本来想把时间放到关键资源那节里面,但是想了想放在这里可能更加合适。
稍微形而上一点来看,我们现在的计算机都是图灵机的实现,我小学就知道图灵完备语言的最小功能集合:读/写变量,分支,循环。用文学一点的说法是:所谓程序就是无数个轮回,大轮回嵌套着小轮回(循环),每个轮回中根据现状(变量)不断的做出选择(分支)。
我说到这里可能聪明的读者会猜到我想说什么:如果我们讨论可观测性脱离了周期,就毫无意义。而周期的定义又是灵活的,对于人而言,大周期显然是一辈子,小周期可以是一年一日,甚至周期可以不用时间跨度作为单位,比如一份工作的周期…
对于一个数据库软件而言,什么是一个合理的周期?是一条 SQL 的执行周期?还是一个事务从 Begin 到 Commit ?这里没有标准答案,但是我个人建议,周期越贴近终端用户的使用场景越实用。
譬如,在数据库中,选择单条 SQL 的执行作为周期不如选择事务的周期,事务周期不如应用程序一个请求全链路的周期。其实 TiDB 在很早就引入了 OpenTracing 来追踪一个 SQL 的执行周期内到底调用了哪些函数,花费多少时间,但最早只应用在了 TiDB 的 SQL 层内部(熟悉我们的朋友应该知道我们的 SQL 和存储是分离的),没有在存储层 TiKV 实现,所以就会出现一条 SQL 语句的执行过程往下追到 TiKV 就到了一个断头路。
后来我们实现了把 TraceID 和 SpanID 传到了 TiKV 内部这个功能才算初步可用,至少把一个周期的图景变得更加完整了,本来我们打算就止步于此,但是后来发生了一个小事情,某天一个客户说:为什么我的应用访问 TiDB 那么慢?然后我一看 TiDB 的监控,没有啊,SQL 到数据库这边基本都是毫秒就返回了,但是客户说:你看我这个请求也没干别的呀,两边怎么对不上?后来我们把 Tracer 加进来以后才知道客户这边的网络出了点问题。
这个案例提醒了我,如果能做到全链路的 Tracing,这里的全链路应该是从业务端请求开始计算,去看待生命周期才有意义。所以在此之后我们在 TiDB 里面通过拓展 Session Variable,能够支持用户将 OpenTracing 协议的 Tracer 信息通过 Session Varible 传入到 TiDB 的体系中,打通业务层和数据库层,能够真正实现的一个全生命周期的跟踪,这个功能也会在很近的未来的版本中和大家见面。
说了这么多,总结几点:
- 时间也是重要资源。
- 抓 Sample 也好,做 Trace 也好,选对周期很重要。
- 周期越贴近业务的周期越有用。
可观测性能救命的时刻:事后观测
我相信没有人会没事天天看着监控界面,其实仔细想想,当我们需要可观测性的时候,多数是已经出现了可感知的故障或者很明确的风险。此时的系统可能已经“病入膏肓”,或者在火烧眉毛的时候还不知道啥原因导致,其中的根因或是之前某个时间的一些不太显然的异常变化,这时候发现之前除了正常的 Metrics 外并没有更多的信息,我们当然不会永远开着 CPU Profiler,通常 Profiler 都是手动触发,但是如果是在事后复盘原因的时候,能够有事发之前的 CPU Profile 记录,对于问题的解决和归因会有巨大的帮助,所以一个比较好的方案是:在一个相对短的时间间隔下(比如分钟级)自动的开启 Profiler,自动把诊断结果保存下来,就像定期做一个深度体检记录一样,老的记录定期删除就好了,万一出事,可以快速往前回溯,救命的效率会更高。
另外相信我,做 Profile 其实也不会有什么明显的性能损耗(何况还是间歇性的),这个功能我们叫做:Continuous Profiling,这个功能很实用,也会很快和大家见面。
根据我们的经验,结合上面一节,有了完善的 Tracing 系统,大部分的 Debug 过程在 Tracing + Log 就能找到问题的根因。
最好的可观测性是能够指导用户:“我接下来该做什么?”
上文中提到了行动,我在观察老师傅处理问题的时候发现一个特别有意思的现象:有经验的开发者总是能够很快通过观测,决定自己接下来该做什么,不需要查阅资料什么或者等着别人指导,完全处于一个心流的状态(例如在 TiDB 里面看到数据在集群内部分布不均或者有热点,就知道去修改调度策略或者手工 split region),但是新人在这一步总是会卡着,要么去 Google 要么去翻文档,内心OS:「我看到问题了,然后怎么办?」,如果这个时候,系统能够给一些接下来应该观测哪些指标,或者行动建议,会更加友好,目前能做到这一点的系统不多,如果能做到这一点,相信你的系统已经在可观测性上做得很棒了。把这个点放在可观测性的最后其实是想借着这个话题引出可交互性。
可交互性
在聊基础软件的可交互性之前,我想先和大家回顾一下计算机的历史,在我看来计算机历史的一个侧写就是人机交互的进化史:从第一张图,看着一堆线我也不知道怎么操作,到现在我从来没看过 iPhone 的说明书就能够熟练使用,这个背后其实是多个学科的进步(包括不限于心理学、认知科学神经科学、哲学、计算机科学)。
回到我们这个领域,基础软件这个领域因为离大众确实有点远,过去很多设计是由工程师完成的,我们这类人,普遍有点缺乏对人性的理解(no offense ),一个很典型的逻辑是:“我自己是人,所以我了解人。我的设计自己能理解,因为我是人,所以别的人也能理解。如果别人不会用,就去看看文档就好了(此时还有一个嫌弃脸)”。
当我们复盘一些故障时,经常会得出「使用者操作不当」的结论,但是这真的是根因吗?我在之前的公司曾经历过一个事故给我留下了深刻的印象:当时内部有一个自己做的分布式文件系统,就像所有的文件系统一样,它有一个 shell,可以支持一些 UNIX Style 的命令操作。
有一次,一个工程师执行了一行命令:rm -rf /usr /local/...(注意 /usr 后边的空格),然后系统很听话的开始删除自己...最后这件事情的复盘并没有责怪这个操作者,而是惩罚了这个系统的设计者(当时那个公司的老板),因为这是个坏的交互设计,哪怕在删除重要文件夹前确认一下或者通过权限系统保护一下都不至于发生这个事情,机器确实在按照逻辑工作,这个地方也没有 Bug(甚至这个删除还很高效,毕竟分布式系统 LOL)。
在后来作为工程师漫长的岁月中,我渐渐理解到一个道理:最好的工程师能在逻辑和感性中间找到一个平衡,良好的设计源于对技术和心理的理解,毕竟我们是在为人写程序。
作为软件的使用者,我们与其说是在使用,不如说我们是在和软件「对话」。那既然是对话,那么就意味着这是一个交互的过程,什么是一个好的交互体验呢?我试着总结一些写给软件设计者的原则,试着第一次干这事,不排除以后会补充。
没人读文档:一条命令启动和探索式学习
承认吧,没有人会看说明书。我们拿到一部新的 iPhone 时候,第一反应一定是开机(很神奇吧,我们似乎下意识就知道开机键在哪)肯定不是看说明书找开机按钮,开机就开始通过手指来探索新的世界,很浅显的道理,为什么在系统软件领域就要先熟读文档才能上岗呢?
我经常教育我们年轻的产品经理:“你的用户充其量会在你的 GitHub 首页或者文档的 Quick Start 部分上停留 10 秒,甚至连看完这个文档的耐心都没有,他们的潜意识会寻找「深色背景的字」(shell 命令),然后把里面东西复制到自己的终端里看会发生什么,除此之外啥都不会做,如果这第一条命令失败了,不会再有后面什么事了,所以记住你只有一次机会”。
一个小例子就是当时在做 TiUP(TiDB 的安装部署工具)的时候,我反复告诫 TiUP 的产品经理,首页里不要废话,就一句命令,贴进去就能用:
其实这个例子可以更延展一点,我记得疫情之前有一年我在布鲁塞尔参加 FOSDEM,晚上在会场附近的酒吧和一位来自英国的 DevOps 聊天,可能也是喝多了,他说:“不能用一个 apt-get install 就安装成功的系统软件不是一个好软件”,话糙理不糙。
那你可能要问,如果确实有一些信息或者概念需要传递给用户,如果用认知心理学里面的概念,可称之为构建 Mental Model(心智模型),最好的方式是什么呢?我自己的经验是:探索式的学习。支持这种认知构建模式的系统通常需要有 Self-Explanatory 的能力,即告诉用户第一步(例如 iPhone 的开机)之后用户的每一步都能够利用上一步行为的输出,决定下一步的行为完成学习。
举个例子:MySQL 的系统表想必 MySQL 的用户都不会陌生,你只要用一个交互式的 mysql-client 链接到一个实例上,也不用等着系统告知 INFORMATION_SCHEMA 里面有什么,只需要用户 SHOW TABLES 一下就知道了,然后再使用 SELECT * FROM 语句就可以一步步探索 INFORMATION_SCHEMA 里面具体表的内容。这就是一个 Self-Explanatory 的绝佳例子(这个例子里面有个前提就是 SQL 作为统一的交互语言)。
另一个特别好的例子是 Telegram 的 Botfather,我相信给 Telegram 写过机器人的朋友一定会对 Botfather 的好用程度印象深刻,我放一张图你就懂了:
Telegram 是一个聊天软件,Botfather 巧妙的利用了 IM 的交互模式应用到了一个相对枯燥的 bot 开发流程里面,而不是冷冰冰的丢给用户一个 URL https://core.telegram.org/bots/api ,让用户自己研究去。
这一节最后一句话想送给大家,有一个无从考究的都市传说是这么说的:鱼的记忆时间只有 7s,我想说,人也一样。祝你做出一个“鱼”都能用好的软件。
帮用户多想一步,告诉用户半步,让用户自己走半步
我很喜欢看科幻小说,很多科幻小说探索的一个终极哲学话题:我们是否真的有自我意识?尽管我们认为我们有,但是在软件输出 Unknown Error 的时候,你肯定希望有一个声音告诉你接下来该怎么办,对吧?
一个优秀的基础软件,在输出负向反馈的时候,最好的做法就是建议开发者接下来该干嘛。我举一个很经典的例子,所有的 Rust 开发者都有过被编译器调教的日子,但是这个过程严格来说其实并不痛苦,比如,看下面的截图:
Plain Text
error[E0596]: cannot borrow immutable borrowed content *some_string
as mutable
--> error.rs:8:5
|
7 | fn change(some_string: &String) {
| ------- use &mut String
here to make mutable
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ cannot borrow as mutable
之所以不痛苦是因为编译器明确告诉了你哪里有问题、原因,以及下一步应该干嘛,普通编译器可能打印一个 cannot borrow as mutable 就仁至义尽了,但是一个好体验的编译器会多帮你想一步。
回到自我意识的问题,我之前听过一个段子:一个测试工程师走进一家酒吧,要了 NaN 杯 Null,一个测试工程师化装成老板走进一家酒吧,要了500杯啤酒并且不付钱,一万个测试工程师在酒吧门外呼啸而过,一个测试工程师走进一家酒吧,要了一杯啤酒';DROP TABLE,最后测试工程师们满意地离开了酒吧,然后一名顾客点了一份炒饭,酒吧炸了 LOL。
这个故事告诉我们,作为软件设计者,你永远没有办法穷举使用者的想法,与其让用户放飞想象力,不如你自己设计好故事线,一步步让用户跟着你的思路走。但是为什么还要留半步?我的答案:
- 「参与感」会带来幸福感,人有时候挺矛盾的,一边希望机器自动干完所有的事,一边还期待自己有主动权。有时候即软件已经知道下一步一定是做某些事情,但是留下临门一脚让操作者完成相当于把成就感都赋予了操作者。
- 选择的权利交给操作者,尤其在面对一些单向门的决定时,go or no-go 还是应该交给人。
对于这点,我还有几个小建议:
- 对于一些操作可能会引发多个连续操作的模式(例如 terraform 的部署脚本,或者集群变更之类的功能),提供一个 Dry Run 模式是必要的,只输出操作,不执行操作。
- 对于上面这种批处理型的操作,尽可能设计 save point,不用每次都重新来(类似断点续传),体验会好很多。
- 遇到真的 Unknown Error 要输出各种帮助 Debug 的上下文信息,最后在错误日志里提示用户到哪个链接提 Github Issue,然后最好在 URL Link 里帮用户把 Issue Title 填好(让用户自己决定是不是发 Issue)。
统一语言:控制器和控制对象
我访谈过很多系统工程师,我有个必问的问题:你心中最好用的(数据库) cli 工具是哪个?绝大多数几乎下意识的回答 redis-cli。其实我自己也会给出同样的答案,后来我想这是为什么呢?
「控制器」-「被控制对象」是一个在基础软件中非常常见的模式,就像我们在操作电视机的时候,绝大多数时间是通过遥控器一样,所以可以认为用户对电视机的第一和大多数触点其实是遥控器,所以类比到基础软件中,对于控制器的设计其实非常关键,做好控制器,我觉得关键点是:
- 构建统一的交互语言
- 自洽且简洁的概念模型
我稍微用 redis-cli 作为例子解读一下。使用过 redis-cli 的朋友都知道,所有的操作都遵循 [CMD] [ARG1] [ARG2] ... 的模式,在 redis-cli 没有例外,不管是操作数据,还是修改配置,所有的一切都在一个统一的交互语言下,而且这个语言一目了然,而且这个语言里面有一些很自然的约定,例如命令(CMD)永远是几个不包含符号的字母组成。
Bash
redis 127.0.0.1:6379> SET k v
OK
redis 127.0.0.1:6379> DEL k
(integer) 1
redis 127.0.0.1:6379> CONFIG SET loglevel "notice"
OK
redis 127.0.0.1:6379> CONFIG GET loglevel
1) "loglevel"
2) "notice"
其实这点在刚才提到探索式学习那节 MySQL 的例子也是一样的,SQL 本身就是一个统一的交互语言,只是没有 Redis 这么直观。
第二点是概念模型,Redis 的优势在于它是一个 Key-Value 数据库,所以概念很简单:一切都是 Key-Value,观察它的 cli 工具,你仔细品一品就知道,作者在尝试将所有的功能和交互都往这个 Key-Value 的模型上映射,这个是很自然的,因为我们之所以会使用 redis-cli,首先是我们接受了 Redis 是一个 KV 数据库的现实,所以在使用 redis-cli 的时候的一个自动就成立心智假设就是 Key-Value 模式,这在使用 cli 的时候一切的操作都会变得很自然。这一点在很多优秀的数据库软件里面应用的很多,例如 Oracle,理论上可以依赖 SQL 来对软件本身做所有操作,因为用户只要在使用 Oracle 就默认应该是知道关系模型和 SQL。
说了正面的例子,我们聊个反例:大家知道 TiDB 主项目(不包括其他工具,例如 cdc、binlog)至少有 3 个 Controller 工具:tidb-ctl /tikv-ctl / pd-ctl,虽然 TiDB 确实是一个由多个组件组成的分布式系统,但是对于用户来说,多数时候使用对象其实是 TiDB 作为一个整体(数据库软件),但几个 ctl 的使用方式都不太一样,比如说 pd-ctl 是一个可交互式的控制器,而且影响的范围大概是 pd 本身和 tikv,tikv-ctl 的功能上也有一些交集,但是只是针对单个 tikv 实例使用,这点太令人费解了,tikv 明明是一个分布式系统,但是 tikv-ctl 却是一个针对单点的控制器?那么控制 tikv 到底应该用的哪个 ctl 呢?答案:多数时候用 pd-ctl(惊不惊喜,意不意外?)。
就像你有一个电视机,但是需要用三个遥控器来控制,而且真正控制电视的那个遥控器叫做:机顶盒,这种问题在日常生活中大家都认为是一个理所应当的设计问题,但是在基础软件领域大家的容忍度怎么似乎突然就变高了?
No Surprise: 不怕麻烦,就怕惊喜(惊吓)
我不知道是否是一个普遍现象,基础软件的用户在面对错误(尤其是因为坏交互造成的),通常会先自责和内疚,认为是自己的问题,很少会归因于软件。尤其是当能够比较熟练的操作一些复杂又分裂的软件的时候,很多人会觉得这是一种「技能」,毕竟没有人愿意别人看着自己的笨拙操作。
这背后其实有着很深层次原因(Hacker Culture 里面多少有点崇尚复杂的倾向),但是我想说:这就是的软件的问题!就像我从不避讳说我就不会用 gdb,不是因为我智商不行而是因为这个东西真是太难用了。
但是我见过很多人真的是以熟练使用命令行 gdb 作为炫耀的资本,回到前面提到的那个反例,我在一个 TiDB 的深度用户那边观察他们的操作员做日常的运维,这个操作员非常熟练的在各种 ctl 之间切换和操作,他不觉得有啥问题,甚至觉得有点厉害,后来我想了下,人的适应性还是很强的,真正让人困扰的事其实并不是麻烦,而是当你在对系统做出一个操作的时候,通常会带着一个下意识的假设,例如一个功能的名字叫「xx开关」的时候,用户在打开开关的时候的预期应该是有一个正反馈,但是如果结果并不是这样的话,用户会非常有挫败感。这里有个真实的故事,我们在 TiDB 5.0 里面引入了一个新功能,叫做 MPP (Massively Parallel Processing),即大规模并行处理,我们有个开关配置叫做:tidb_allow_mpp
不知道大家有没有注意到问题:作为一个开关型的配置,当设置成 OFF 的时候,是一个 100% 的负反馈,这没有问题,但是问题在设置成 ON 的时候,这个功能是否启用会依赖优化器的判断,也就是有一定概率 MPP 功能不会生效,这就像一个房间里有个控制灯的开关,当你关的时候,灯一定不会亮,当你开开关的时候,灯不一定亮(灯觉得房间内的光线足够,没必要亮...),你一定不会觉得这个灯智能,你一定会觉得灯坏了。上面这个配置的一个更好的写法应该是:
tidb_mpp_mode = ON | OFF | AUTO
这个写法我都不用解释,你也不用看文档,是不是一眼就明白怎么用?好配置应该是自解释的。通常来说,配置项是破坏用户体验的重灾区,后边讲反馈的时候展开讲讲。
UNIX 哲学里面有一条「安静原则」,说的是如果程序没什么特别事情要表达,应该保持安静。具体的一个表现就是鼓励命令行程序如果成功执行,不需要输出东西的话,就直接以 0 作为 return code 退出就好了,其实对于这一点我是持保留意见的,用户的行为如果是符合预期的结果,应该用一个明确的正向反馈作为奖励(例如打印一个 Success 都好),不要忘了人性大师巴普洛夫。
反馈:暴露进展,不要暴露内部细节
刚才正好提到了反馈,我觉得将反馈称为好体验中最重要的一环都不为过。学过控制论的朋友的都知道反馈是非常重要的概念,前面提到的 Self-Explanatory 之所以是个好体验就是因为反馈的及时性。
但是我惊讶的是,很多基础软件在交互反馈部分设计得糟糕得令人发指,举一个我熟悉的例子,某些数据库软件在接收到一个复杂查询的时候,当敲下回车,通常就 Hang 在那里了,可能确实数据库程序在后边辛苦的检索和扫描数据,然后隔了几分钟直接返回一个结果(或者挂了),过程中并没有反馈扫描了多少数据和预期要扫描多少数据,其实这个体验是很差的,因为这个信息就是进展(这点上 ClickHouse 做得很好)。反馈是需要精心设计的,我的几个经验是:
1.反馈一定要即时,最好是敲完回车后 200ms 内一定要有反馈(人的生理反应时间,超过这个时间反馈人就会有卡顿感),顺滑的感觉是靠反馈创造的。
2.反馈进展,不要反馈细节,不要反馈需要上下文才能读懂的细节(除非是 Debug Mode),这里给出一个我们自己的反例(https://asktug.com/t/topic/2017):
Bash
MySQL [test]> SELECT COUNT(1) AS count, SUM(account_balance) AS amount, trade_desc AS type FROM b_test WHERE member_id = 「22792279001」 AND detail_create_date >= 「2019-11-19 17:00:00」 AND detail_create_date < 「2019-11-28 17:00:00」 group by trade_desc;
ERROR 9005 (HY000): Region is unavailable
这个 Case 坏在哪里呢?很显然,对用户来说,Region 是一个 TiDB 内部概念,一个很自然的问题是:什么是 Region(我在前面埋了个伏笔,不知道你注意到没有)?为什么 Select 数据和 Region 相关?为什么 Region is unavailable?我该怎么解决这个问题?暴露给用户这个信息是无用的,反而给用户创造了噪音。这个 Case 的原因是 TiKV 太忙,无法返回需要的数据,一个更好反馈应该是:具体的哪台 TiKV 因为哪些数据(用用户能理解的形式,如:哪张表,哪些行)读取不出来是因为 TiKV 太忙,最好还能告诉用户为什么忙,怎么解决,实在解决不了至少贴个 FAQ 的链接(我见过有软件直接贴 StackOverflow 的 Search URL 的 LOL)。
3.对正反馈设置一些 milestone,例如一个服务器程序开始正常对外提供服务的时候,打印一个 Ascii Art,不同日志级别用一些带颜色 Label,这是给用户一个明确信号,这点 redis-server 做得很好。
通常对于可交互命令行程序的反馈还是容易设计的,一个非常麻烦的事情是,基础软件通常非常依赖配置文件,配置的问题就是修改配置到确认生效的反馈周期通常很长,一个经常的场景是:修改配置 - 重启 - 观察效果,而且通常配置是存储在配置文件里面,这也造成修改文件操作的反馈感是极差的,因为用户也不知道到底这个操作有没有生效,尤其是一些配置的生效并不是太明显,一些比较好的实践如:程序在启动的时候打印一下读取了哪个配置文件以及这个配置文件的内容是什么;设计一个类似 print-default-config 之类的命令行功能,直接输出模板配置,省得用户自己 Google。
另外对于分布式系统来说,配置的问题更加复杂,因为存在并不是本地配置和全局配置的区别,以及更新后的配置分发的问题,包括滚动重启的问题(重启进程才能让配置生效本身就不是一个好设计),老实说目前我还没有特别好的方案,可能的思路是是使用类似 etcd 这样的分布式全局配置中心或者(对于数据库来说)通过一些全局的配置表来实现。但是总体的原则是:集中比分散好;即时生效比重启生效好;统一交互(修改和读取配置的方式)比多种方式交互好。
写在最后
终于写得差不多了,但是这篇文章我觉得仅仅是抛砖引玉,一定还有很多好的实践没有总结出来,也希望有想法朋友找我一起探讨,我揭晓一下最开篇留下的一个悬念,为什么要在第一篇文章中将可观测性和可交互性放在一起写,其实这个是来自经典的认知心理学中的人行动的模型[3]:
当用户使用软件时,需要面对的两个鸿沟:一个是执行的鸿沟,在这里,用户要弄清楚如何操作,与软件「对话」;另一个是评估的鸿沟,用户要弄清楚操作的结果。我们作为设计师的使命就是帮助用户消除这两个鸿沟,正是对应到文章中的可观测性和可交互性。
设计出使用起来令人愉悦的软件是一门艺术,也不见的比设计出一个精妙的算法或者健壮的程序简单,从某种意义上来说更加难,因为这要求设计者真的要有对人和软件两者都有深入的理解以及倾注感情,最后送给大家一段来自 Steve Jobs 的话共勉:
The design is not just what it looks like and feels like. The design is how it works.
参考:
[1]: 我眼中的分布式系统可观测性, 黄东旭, 2020
[2]: Overtaxed Working Memory Knocks the Brain Out of Sync | Quanta Magazine
[3]: The Design of Everyday Things, Donald Norman, 1988
目录