编者按:高可用架构分享及传播在架构领域具有典型意义的文章,本文由雷果国在高可用架构群分享。转载请注明来自高可用架构公众号「 ArchNotes 」。
雷果国,2014 年 11 月加入 nice,负责服务端在线业务,擅长 PHP,曾自发翻译过《Extending and Embedding PHP》一书及PHP官方手册部分模块。喜欢利用所学构建自己的工具链,思考系统和架构设计方面的问题。
nice 是一款图片社交 App,目标是让人们发现生活的美好。产品的核心体验是基于生活方式的社交。
我们期望通过图片、直播、标签、潮牌新品等方式,让用户表达自己的生活方式,以这些内容作为基础,为用户提供社交场景。产品方面,目前我们仍然在积极探索怎样更好的为用户提供这种价值。
现阶段,nice 服务端主要面对以下几方面挑战:
系统设计,面向变化,必须能够很好地支撑“产品探索阶段”需求的多样性。
稳定性,避免稳定性问题对现有用户造成伤害,同时还需要应对业务突发性增长。
协作,麻雀虽小五脏俱全,服务端作为客户端、策略推荐、大数据、QA、运营、产品等各团队的桥梁,如何通过技术或非技术手段解决好各方的桥接。
“推倒重来”:nice 的重构之路
刚加入 nice,我就接到一个极具挑战性的任务,重构服务端整体业务及框架。和很多创业团队一样,我们在成长过程中积累了一系列技术债务。
旧系统是使用 CI 框架编写的,没有模块的划分,没有明确的分层,入口处直接进行各种业务处理,几乎没有复用。
API 的版本管理,是直接目录拷贝,随着业务的发展,已经需要同时维护十多个版本的接口代码,痛苦不堪。
代码中充斥着 if ($isAndroid && $appVersion >= 3) 这样的兼容逻辑。
以至于客户端/服务端联调基本靠喊。我相信初创公司很多朋友都经历过这些问题。
旧系统架构
旧系统架构是一个最典型的一体化的应用架构:后台、 HTML5、接口全部都揉在一起。
面对当时的状况,首先分析要解决的问题,认为下面 3 个问题比较关键。
结构性问题。代码结构混乱,无法复用。
客户端差异管理。接口版本拷贝导致重复代码量巨大,主要针对特定客户端的特殊需求/灰度/小流量等问题。
客户端/服务端 RD 协作问题。
分层和模块化
首先,从大的角度来考虑,可以用简单的两层架构来解决第一个问题。
应用层和服务层在编码层级进行划分:
应用层解决入口的问题,比如交互协议、 鉴权、 Antispam 等通用服务接入以及各端个性化需求。
服务层解决业务逻辑的问题,将服务层按照业务做垂直的模块划分。
通过层次和模块的划分,代码管理变得清晰,逻辑复用性大大提高。同时,业务划分也为后面的业务隔离和分级管理提供了基础支持。
两个基础组件
上述客户端差异管理以及客户端与服务器协作,通过框架的两个基础组件来解决:
ClientAdapter:客户端适配器,用来处理所有客户端差异导致的逻辑问题。
CKCR:CheckAndCorrect,数据的检查和修正,用来控制输入输出协议,解决客户端/服务端 RD 协作的技术层面问题。
ClientAdapter 组件
先来看一个 ClientAdapter 的应用场景。
以上面配置为例,可以实现以下常用的类似规则。
所有客户端为 3.1.0 及以上版本,支持"打招呼"功能。
所有客户端为 3.1.0 以上版本,灰度渠道 abc36032 为 3.1.0 版本,支持"表情"功能。
(点击图片可以全屏缩放)
大家看以上 nice 代码,通过这种方法,逻辑上 nice 应对的是各种 "Feature",而不是具体的客户端环境。上面例子就是 ClientAdapter 的一种应用场景。
ClientAdapter 的整体结构
ClientAdapter 最基础的部分,是抽象出一个”客户端运行时环境”的概念,用它来描述发起每次请求的客户端的各种信息,比如系统、App 版本、IP、网络制式、 网络运营商、 地理位置等。另外,它提供一种简单的描述规则,用来描述一种受限的客户端环境。
应用层面,它仅对外暴露 checkEnv() 接口,用来检查当前客户端是否满足给定的描述规则。在这个基础设施之上,nice 上层有很多种应用。
比如原本客户端的差异会导致nice面对复杂的客户端适配,通过 NiceFeature,在 Feature 机制下,RD 面对的其实是一个个产品迭代的 Feature。再比如在 NiceUrl 中,nice 中实现了 CDN 的统一调度,通过 ClientAdapter,我们可以灵活控制各个地区使其采用不同的调度策略。
另外,在处理用户分流方面,这套机制所提供的灵活性也能够满足 nice 按照多种维度进行实验用户抽取的需求。
ClientAdapter 开源地址
ClientAdapter 这个组件实现非常简单,只有 200 行。代码已经摘出放在 github 上了,供大家参考,网址为:http://t.cn/RGnqnpj。
补充一点,ClientAdapter 的设计参考了 C 语言中的常用手段。在 autoconf 阶段,将系统的各种环境信息定义为各种各样的 HAVE_XXX 。这样就达到了环境本身的复杂性和实际的业务代码进行解耦的目的。
CKCR 引入
上述问题 3,客户端/服务端 RD 的协作问题,这个问题分两部分。
技术层面:协议层的约束。
流程层面:怎么配合工作。
技术层面上,要解决的问题是怎么让接口协议确保执行。从输入的角度看,我们要不信任客户端,只要协议约定建立就得按规则来,避免客户端被“坏人”控制,对服务造成伤害。从输出的角度看,要将业务层返回的数据确保按照协议约定传输到客户端,避免导致客户端产生不期望的结果,比如常见的类型不 Match 导致 Crash。
一言以蔽之,就是对输入数据的校验和输出数据进行修正。因此,nice 在这里引入了一层叫 CKCR 的组件,全名为 ChecK && CorRect。
CKCR 实现
CKCR 实现了一套很小的描述性语法规则。通过这套语法来描述要对数据进行的校验和修正。校验和修正行为是可以自由扩展的,下面是它的一个应用示例。
(点击图片可以全屏缩放)
这个示例中,$data 代表要被处理的数据,$ckcrDesc 就是这个语法规则的描述串。它描述的规则如下。
整体数据是一个 KV 数组(Mapping),只保留 user 和 shows 两个子键的数据。
user 也是一个 KV 数组,它的 id 是 int 类型,name 是 str 类型。
shows 是一个数组,数组中每个元素是一个 KV 数组,过滤掉它的 id 子键,它的 url 子键,应用 imgCdnUrl 的自定义处理(执行 cdn 调度)。
它和 protobuf、Thrift 的 scheme 有相似之处,也有区别。差异主要在于,CKCR 提供的是一种可扩展的数据校验/修正的通用做法。由于这种扩展性,使得它可以对共性数据,在通用层做更多的文章。比如上面例子中的 imgCdnUrl 就是 nice CDN 调度的统一挂接点。
CKCR 内部结构及语法规则
(点击图片可以全屏缩放)
上面是 CKCR 的基础功能。后来在应用中发现,在系统各个场景下,系统核心数据输出的数据结构大部分是一样的。因此,CKCR 描述串的复用性就成为了一个问题。
为了解决这个问题,nice 在 CKCR 编译之前,引入了预处理的机制。可以通过特殊的语法,引用固定的数据结构描述。这个机制引入之后,带来了一个附加的好处,即沉淀系统的核心数据结构。
(点击图片可以全屏缩放)
客户端/服务端协作问题,技术层面 nice 通过这个组件解决。那么,人的协作问题怎么解决?
首先,CKCR 的描述规则是简明的。所以,它可以直接作为接口文档输出。
其次,在接口文档的基础上,nice 服务端和客户端 RD 之间的协作,流程上有一个明确的方案。
定协议:双方 RD 沟通,约定接口协议并提供文档,双方各自进入设计阶段。
假数据:服务端 RD 快速提供 Mock 数据的伪接口,供客户端 RD 基础功能自测使用。
真接口:服务端 RD 提供真实接口,双方联调。
这样分步的开发方式,基本就解决掉了”联调靠喊”的问题。双方的工作基本解耦,并且也基本不影响双方的开发进度。
CKCR 开源地址
CKCR 这个组件的源代码,已经摘出来放在 github 上了,网址为:http://t.cn/RGn57Xk。
第一阶段总结
上面这些就是 nice 应对第一阶段 3 个问题的方案:
分层和模块化:通过两层架构,解决掉结构性问题。
客户端适配器:解决掉客户端差异的问题。
CKCR:通过 CKCR 及协作流程,解决掉客户端/服务端 RD 协作的问题。
这个阶段,主要通过整体的重构,解决掉开发的问题,同时为未来的架构调整铺平路。
当时的做法是”推倒重来”,现在回顾,这种选择就当时的情况来说是正确的。在那个时间前后,我们没重构系统所遗留的问题,在系统变得更加庞大之后,变得更加棘手。
不过,话说回来,“推倒重来”的重构之路,毕竟还是充满了各种风险的,做这样的决定前,一定要做好充分的资源和风险评估。
为稳定性填的那些坑
在完成整体性的重构之后,nice 进入了业务的快速开发。2015 年 3 月还搞了一个月的 SpeciaForce,研发团队几乎所有人都住公司附近,7 * 14 小时以上的工作量。那段时间的冲刺,为我们的产品带来了日活等关键数据的提升,接口 PV 也达到了最高峰的 5 亿/天。
直到 2015 年 8 月,服务的稳定性经受了很大的考验,说实在的,我当时都快崩溃了,最重要的几类问题如下。
1、MySQL 扛不住
nice 的 MySQL 集群最初就是单实例单库,一主四从,机械硬盘。2015 年 4 月,跟很多业务增长很快的团队一样,为了快速解决问题,OP 把所有的 DB 都换成 SSD,收益非常大。
另外,由于考虑单库导致服务无法隔离,主库的写入也成为瓶颈。2015 年 3 月左右,nice 开始着手考虑分库/分表的事情。分库的方案,主要按垂直的业务进行划分。
技术债真是欠不得!!!
分库整个耗费了两位同学差不多半年的时间。接下来又用了大概一个季度的时间,针对系统的核心大表,进行表的拆分。
在 MySQL 这块儿,我们的教训如下。
用硬件解决问题,性价比非常高。
库的业务线划分,表的规模评估,这样的事情,千万马虎不得。提前做可能多几人天;拖后做可能就像我们需要甚至超过 1 人年的时间去擦屁股。
另外一方面,nice 对 Redis 的依赖也是很重的。一部分数据,是典型的 Cache用法。在线业务访问 Cache 没有的时候,会自动 fallback 到 DB,去冲 Cache。另外一部分数据,是准持久化数据。这部分我们在线业务不会 fallback 到 DB。
2、Redis 扛不住
nice 的 Redis 也是单集群。大概 2015 年四五月的时候,随着业务的快速迭代,很多新功能上线,Redis 的压力迅速增大,开始偶尔出现 Redis 故障。那个时候,nice 就开始了 Redis 的服务拆分。因为业务模式比较简单,所以 Redis的拆分速度是比较快的。
但同时,由于故障中丢过数据,我们决定在 Redis 高可用方面做一些自主开发。主要是针对平滑扩容、 故障自动切换等方面。
由于这方面经验并不是很足,导致出了不小的问题。最严重的一次,上线试运行阶段没有发现问题,但切到全量后,多个集群节点接连出现问题。硬扛了两天左右,最后扛不住,只得往回切换。然而,当时又面临机器资源的问题,做不到一次性全量切回,只能逐集群切换,同时搭上几个 RD 去写几乎所有的准持久化数据的恢复脚本。
Redis 这块儿的教训是非常惨痛的,从 2015 年的踩坑经历来看,我们的收获是如下。
压力与容量评估的经验
压力相关的问题,还是用隔离/拆分的思路解决。
基础的服务监控是必须的,CPU、 内存、 磁盘、带宽等基础资源的监控的实现成本不高,但往往能帮我们提前发现问题。
服务的容量评估,是需要好好思考的。对于在线业务,会 Fallback 到 DB 的 Cache 业务,还要小心故障后穿透的风险。
数据相关的经验
准持久化的数据,如果 Redis 没有做好容灾方案,最好前准备全量恢复数据的备案。不然出问题了现写代码就跪了。
要动线上数据的话一定要准备好回滚方案,不然出问题可能就是灾难。
最后说下自主研发的看法,在技术规模达不到的前提下,建议还是利用已有的成熟方案。即便硬性条件达标,要做这件事,也得每一步都非常小心谨慎。
3、前端机扛不住
最后一个问题,前端机的压力问题。
首先,初创公司规模比较小的时候,接入层一般比较少出问题,不过还是建议做好基本的对“坏人”的防范或预案。
再来看前端机集群,我们出过的问题基本可以归为两类。
客户端的轮询出问题,用户对服务端造成大量请求。
后端服务故障,导致前端机处理慢或者干脆连接不上,这样前端机的进程池很容易就打满了。
因为我们已经对服务的资源进行了划分,业务上也有了模块的划分,所以这件事情的处理就相对容易。大概的思路如下。
前端机根据业务分级。核心业务通常变更较少,其他业务变更导致的故障,避免对核心业务有影响。
降级:我们提供了一套降级预案,出问题能立刻通过上线做降级处理,主要有两类策略。
a. 请求端:应对某个用户群或某个业务出问题;
b. 后端服务:某个依赖的后端服务故障。
后端服务容灾:目前 nice 是增加了一层 LVS 来做后端服务的故障切换(针对 MySQL/Redis 内部服务);另外一点,在前端机应用侧,nice 也做了一层对后端服务的评分机制,避免应用侧才能检测到的问题被忽略掉。
做完上面两个阶段的事情。我们的服务端架构现状大体如下。
(点击图片可以全屏缩放)
nice 现在的痛点和下一步计划
2015 年 8 月之后,经过 RD 和 OP 的共同努力,服务稳定性的问题得到了保障。同时技术团队迅速发展,也有了完整的数据和策略团队的同学。在多方对接的过程中,服务化逐渐就变得越来越重要。
另外,自身团队的规模也在扩大,所有的在线业务代码都在一起,有时候上线一次性要上 10 多个人的变更。代码库的耦合也成了一个明显的问题。
目前 nice 面临的最重要的问题就是服务化和代码拆分。
针对这个问题,我们目前的基本思路如下。
服务化,不一刀切。支持远程调用服务的同时,也能够支持现阶段的同机部署。避免服务过多,过早引入服务管理的问题。
代码拆分,应用开发框架升级。将库/框架/业务剥离,独立维护,并引入库的依赖管理工具。
在参与创业的这一年多时间中。我有非常多的感悟。
创业,是一种生活方式。你随时会面临各种各样的问题,有些是你擅长的,有些是你不擅长的。但是无论如何,你都要挺身而出。因为选择了,就要承担。
本次分享以实际问题为主,希望同样参与创业的朋友,看到这次的分享,能少走一点弯路。
Q & A
问题1:请问 nice 在 Redis 的高可靠到底采用了什么样的容灾方案,还有就是 redis 的多机房主从同步是怎么实现的?
雷果国:nice 现在的应用是通过 proxy 来做,目前是单机房部署。
问题2:请问架构图中的接入层(网络及七层防护)用的是 nginx、keepalived、LVS、haproxy 等组件吗?
雷果国:是的,nice 使用 haproxy 和 nginx。nginx 上用 waf 做一些基础的规则防护。
问题3:前面即使做了客户端适配层,那后面服务是怎么根据不同的适配情况进行服务,是不是依然需要多版本维护?
雷果国:适配层的关键,在于把环境信息的复杂多样性转化为 Feature。业务上面只是面对 Feature 做开发。
问题4: 用 PHP 做服务层或者以后做服务化,如何解决连接池问题 (机器数量多的时候短连的弊端很多)?
雷果国:选用 swoole 或者自己做一些连接池扩展的开发,能够解决这个问题。但是我认为长期看,PHP 不是为这个场景设计的,所以可作为过渡方案。
问题5:为什么前期不考虑使用云服务来搭建服务?
雷果国:nice 也使用一些第三方服务的,七牛 / 网宿等厂商我们都有合作。
问题6:请问 nice Redis 每片有多大?网络抖动的时候会发生全量同步吗?
雷果国:每个分片会尽量控制在 10G 以内。2015 年故障比较多的时候,我们也曾发生 bgsave 导致问题的情况。
问题7:请问 API 接入如何实现?与图中的 LVS 层使用什么方案?
雷果国:
API 接入,nice 最开始直接用 DNS,因为运营商的问题,后来选用 HTTPDNS,现在是预埋自己的 IP 进去。IP 化访问;
LVS 主要用作 DB / Redis 等服务的故障自动切换。
问题8:请问适配器和 ckcr 你们用了几人,花了多久搞定的重构?
雷果国:重构的整体框架用了两周时间,我一个人写的。业务重构 4 RD + 1 OP 友情支援,用了一个月时间。
问题9:看后端 server 以 PHP 为主,没有考虑用 nginx_lua 或 node.js 等异步非阻塞的技术来提高并发能力么? 单纯用 PHP 扛住 2 亿量的请求是不是还有优化空间?
雷果国:
我觉得优化得先看有无高并发的需求场景,比如 nice 前段时间在做好货的二手交易,预期可能会有类似秒杀的场景,就也考虑过 nginx_lua 等方案,当然最终选择什么方案看实际场景的需求。
PHP 能抗多少量的请求,要看业务复杂度。比如我在上家公司的应用场景,业务请求量非常大,就把在线业务完全简化为一个 Redis get,也没有 web server,直接拿 PHP 对上,单机可以达到 5000+ QPS。
问题10:为什么后端服务的故障切换不用 haproxy 而去选择用 LVS 呢?
雷果国: 这里我们使用 LVS 仅作请求转发,是不走流量的。
本文策划 Tim Yang、刘芸,编辑刘芸、郝亚奇,群转播刘世杰。想讨论互联网架构演进,请关注公众号获取进群机会。转载请注明来自高可用架构 「ArchNotes」微信公众号及包含以下二维码。
高可用架构
改变互联网的构建方式
长按二维码 订阅「高可用架构」公众号