低延迟与用户体验杂谈

最近在做系统设计梳理的时候,明显感觉到「低延迟」已经成为被提及越来越频繁、考量权重越来越大的因素。 并且,越是靠用户近的系统,对延迟越敏感,对用户体验影响越大,对低延迟要求越高。

HTTP/2如今(2018.02)已经逐渐普及,其设计的第一目标就是降低延迟。主要采用了两个手段来解决:

  1. TCP连接复用。连接复用减少了TCP每次握手带来的延迟,同时避免了每次新建TCP连接的窗口慢启动带来的数据吞吐开启延迟。
  2. 使用数据分帧解决队头阻塞问题。当然,这个问题HTTP/2解决得不彻底,具体可以参见《当我们在谈论HTTP队头阻塞时,我们在谈论什么?》了解细节。

HTTP/2毕竟只能解决应用层的低延迟问题。如果要继续降级延迟,就需要下潜到传输层。因此,Google的QUIC和TLS 1.3应运而生。QUIC目前主要是Google主导,除去其自家的服务,如Google搜索首页,G+等,支持的网站还非常少。TLS 1.3则由标准化组织加持,目前在最新的OpenSSL、nginx已经支持。TLS 1.3能够做到新连接3RTT,恢复连接2RTT(TLS 1.2分别是4RTT, 3RTT;而2RTT已经与http的RTT持平!),的确非常吸引人。

回顾WEB技术过去十几年的发展,很多的特性引入和改进都是基于降低延迟。有些是技术层面的,比如上面提到的HTTP/2的低延迟设计、DNS查询缓存、HTTP1.1并发多个TCP连接请求资源、雪碧图等;有些是用户体验层面的,比如异步加载/预加载js资源、优先加载影响首屏渲染的CSS资源、避免使用大表布局、图片渐变加载、过渡动画等。

而这些年技术层面的发展其实都是受物理定律限制的。祭出程序员延迟心经Latency Numbers Every Programmer Should Know:

软件工程师无论是做什么职位和方向,心里都应该对此有B树。

有了这些当前人类所认识的物理极限,才能做到在「在边界内做事情」(这句话不是我说的,第一次看到这句话是吴军老师的《硅谷来信》)。

举个例子,当前人类认知范围内最快的速度是光速,这是一个上限,而广泛使用的光纤通信速度大概是光速的2/3。那么,要想优化上海到加州的网络延迟,无论你如何优化线缆布设以及质量,RTT都不可能低于127ms. 因此,如你所见,这些年虽然新架设了不少新的跨太平洋光缆,但是最优网络延迟没有什么太大变化,反倒是这些光缆带来的扩容让网络拥塞得到了缓解,让我们感觉出口网络「好像」更快了。

那么在光速这个物理边界的限制下,我们要如何降低延迟呢?显然,固定的两点之间的网络延迟是无法突破该边界的(这里不讨论空间扭曲力场?‍♀️)。但是,很多时候,我们要解决的问题是「让用户感觉延迟低」就可以了。

在这个思想下,CDN应运而生。将内容分发到距离用户近的网络节点来降低用户访问延迟。这个idea非常简单,甚至简陋,但是非常有效,并且廉价。

顺着这个思路下去,如果把算力分发到距离用户近的节点,那是不是也可以让用户觉得计算任务也变快了呢?在一定程度上,这是可以做到的。比如,我们使用了数十年运行于浏览器的javascript,以及我们的多IDC、多主架构方案,都有这方面的考量。近来流行的serverless、边缘计算其实也可以是认为是将算力部署到离用户尽可能近的物理位置或业务流程中。

有时候,技术参数指标的提升在短期内是难以低成本解决的。这个时候,尽量避免死磕参数,投入100%的精力去换取1%的性能提升。可以尝试从设计交互上给用户形成反应很快的体验,降低体感延迟。比如,1)耗时的任务后台化并给出进度条;2)区块处理的逻辑任务尝试修改成流处理,加速部分处理结果的输出,典型应用如以视频直播服务为代表的流媒体服务全力优化首屏播放延迟;批处理任务每处理完一个子任务就反馈结果等。

不仅软件系统的延迟影响着用户体验,硬件更是如此。现在人们普遍认为iPhone的用户体验是优于安卓的。因素有很多,那些复杂的系统参数普通用户未必搞得懂,但是很多用户却承认在滑动屏幕的时候iPhone比安卓更加「跟手指」,打开应用也「感觉更快」。「跟手指」这个体验跟苹果的软件+硬件的技术优化有关,目前安卓阵营也没有赶上。打开app快则是典型的交互优化:苹果打开app未必真的比安卓快,但是从点击app图标到显示story board的确非常快。无论苹果是否鸡贼,至少从这个层面看,它的确是非常了解延迟与用户体验之间的奥义的。至于苹果每次升级系统「故意」让老设备变卡……咳,咳,同学,你怎么有又抬杠呢?

喜欢汽车并且喜欢驾驶的朋友一定知道甚至深入研究过这几款车:马自达3/6/BRZ/86-宝马M2/M4-保时捷718/911。排名分先后,每一辆都是不同级别的驾驶者之车,也是很多玩车朋友的玩车升级路线。这些车都是「运动取向」,这是一个非常抽象的概念,普通消费者未必明白,但是你会发现他们的消费者大多会用「操控好」「响应快」来评价这些车。如果你是参数党,你会发现其实这些车在同级别中都不是最好的,但却是给人愉悦感最强的。

由物及人,当上司交给你一个任务的时候,有反馈好过无反馈,快反馈好过慢反馈。

总结

在节奏日益变快的今天,低延迟很多时候意味着良好的用户体验。而做到低延迟可以通过技术优化,也可以通过一些产品的交互方式和有选择的强调某些方面来让用户感觉很快。内容比较分散,但是都是顺着这根主线,希望你也有所思考和收获。

参考文献

Introducing Zero Round Trip Time Resumption (0-RTT)

从答题赚钱APP谈谈接口防抓包

从答题赚钱APP谈谈接口防抓包

这段时间,答题赚钱APP真的是红遍了神州大地:国民老公王思聪的冲顶大会、芝士超人、西瓜视频,甚至连老司机韩路也玩起了这个游戏?。有利益就有动力,毕竟每场奖金都在十万、百万,虽然官方可以在最终答对人数上面掺水稀释每人的奖金,但是的确可能拿到奖金。人类,对这种可预期、强反馈的东西跟吸毒一样……于是,市面上出现了很多所谓的自动AI答题工具。大概可以分为两个流派:

  1. 通过OCR识别题目,然后用搜索引擎搜索答案告诉用户选什么。答案选择策略各异。做得比较简单的就是选搜索结果最多的那个为正确答案。
  2. 直接抓包这些APP下发的题目,然后用搜索引擎搜索答案。

可以看出,主要区别就是如何获取题目的文本。顺便吐槽一句,其实这些工具跟AI有半毛钱关系呀。对于第一种方式,是比较难防的。第二种方式解决办法主要有两种:

  1. 设计之初,使用图片下发题目,而不是直接下发题目文本;
  2. 如果已经走上了文本下发题目这条不归路,那么至少要做到接口防抓包。

这里我们谈一下接口如何防抓包。

为何可以成功抓包?

  1. 接口走HTTP,那么直接是明文传输,也不存在传输层的认证和加密问题,所见即所得。随着大家安全意识的增强,直接这么裸奔的产品越来越少。但是会有人因为各种原因就是不迁移到HTTPS,然后自己实现一个民科版的加密。我们极其不推荐这种做法,主要有以下几个原因:
    1. 大部分工程师都没有密码学背景,设计的算法基本很难完备的考虑数据机密性、完整性和不可抵赖性。
    2. 将应用层与数据传输层进行耦合不仅限制了通用性,如无法进行透明的流量转发,而且会带来非常高的系统维护和迁移成本。
  2. 接口走HTTPS。嗯,数据加密了不是?但是,通过中间人攻击(MitM)也是可以成功抓包的。这背后的基本原理是:在手机上安装一个中间人CA证书(如Charles证书) --> APP信任手机上的证书 --> 中间人可以成功解密流量进行抓包

如何防止抓包?

对于HTTPS API接口,如何防止抓包呢?既然问题出在证书信任问题上,那么解决方法就是在我们的APP中预置证书。在TLS/SSL握手时,用预置在本地的证书中的公钥校验服务器的数字签名,只有签名通过才能成功握手。由于数字签名是使用私钥生成的,而私钥只掌握在我们手上,中间人无法伪造一个有效的签名,因此攻击失败,无法抓包。

同时,为了防止预置证书被替换,在证书存储上,可以将证书进行加密后进行「嵌入存储」,如嵌入在图片中或一段语音中。这涉及到信息隐写的领域,这个话题我们有空了详细说。

预置证书/公钥更新问题

这样做虽然解决了抓包问题,但是也带来了另外一个问题:我们购买的证书都是有有效期的,到期前需要对证书进行更新。主要有两种方式:

  1. 提供预置证书更新接口。在当前证书快过期时,APP请求获取新的预置证书,这过渡时期,两个证书同时有效,直到安全完成证书切换。这种方式有一定的维护成本,且不易测试。
  2. 在APP中只预埋公钥,这样只要私钥不变,即使证书更新也不用更新该公钥。但是,这样不太符合周期性更新私钥的安全审计需求。一个折中的方法是,一次性预置多个公钥,只要任意一个公钥验证通过即可。考虑到我们的证书一般购买周期是3~5年,那么3个公钥,可以使用9~15年,同时,我们在此期间还可以发布新版本废弃老公钥,添加新公钥,这样可以使公钥一直更新下去。

Golang多级内存池设计与实现

Golang多级内存池设计与实现

上个月,牙膏厂intel因为MeltdownSpectre两个bug需要给CPU固件和系统打了补丁。我们生产环境使用的是阿里云,打完补丁后,几台IO密集型的机器性能下降明显,从流量和cpu load估计,性能影响在50%左右,不是说好的最多下降30%麽?

在跑的业务是go写的,使用go pprof对程序profiling了一下,无意中发现,目前的系统gc和malloc偏高。其中ioutil.ReadAll占用了可观的CPU时间。

ioutil.ReadAll为什么慢?

这个函数的签名原型是func ReadAll(r io.Reader) ([]byte, error). 团队的小伙伴非常喜欢用这个函数,其中一个原因是这个函数可以将r中的数据一次性读完返回,不需要关心内存如何分配、如果分配的内存不够了,如何进行内存扩张等。作为一个util函数,这样设计是完全没问题的。但是,IO密集场景下,这个函数的开销就是你需要关心的了。这个函数实际调用realAll读取数据:

其中,capacity是常量值512. realAll函数在调用buf.ReadFrom进行数据读取:

看到这里,原因就非常清楚了:如果要读取的数据大小超过了初始buf大小(默认初始大小为512 bytes), 则会重新分配内存,并拷贝内容到新的buffer中。如果要读取的数据非常大,则会重复多次上述操作。那么优化的问题就转化为如何降低内存重分配和拷贝。

多级内存池的设计和实现

  1. 内存池被按照大小被分为多级。如上图所示,(0, 1024]使用level 0, (1024, 2048]使用level 1. 内存池分级有两个好处:
    1. 可以灵活的规划不同级别内存池的总大小和item数量,适应不同业务。
    2. 实现层面上,可以将一把内存池大锁拆分成多个小锁,减少锁争抢。
  2. 当已分配的内存池耗尽需要扩张时,一次性申请一大块内存,提高扩张效率。如level 0所示。
  3. 代码实现gmmpool,bench结果显示性能提高约19倍:

小结

对于频繁进行内存分配和释放的场景,使用内存池可以显著降低golang运行时的开销。同时也要注意,内存池的内存交给了用户管理,你需要小心检查是否存在内存泄露问题。如果你对性能要求没有这么苛刻,只是想复用一些小对象,那么我们推荐你使用标准库的sync.Pool.

另外,开头提到的阿里云性能问题,即使使用了内存池优化,结果还是非常悲剧。最后阿里云帮我们更换了没有打牙膏厂补丁的机器解决。是不是非常惊喜??