Medium开发团队谈架构设计
背景
说到底,Medium是个社交网络,人们可以在这里分享有意思的故事和想法。据统计,目前累积的用户阅读时间已经超过14亿分钟,合两千六百年。
我们支持着每个月两千五百万的读者以及每周数以万计的文章发布。我们不想Medium的文章以阅读量为成功的依据,而是观点取胜。在Medium,文章的观点比作者的名头更重要。在这里,对话促进想法,并且很看重文字的力量。
我是Medium开发团队的负责人,此前在Google工作,负责开发Google+和Gmail,还创立了Closure项目。业余时间我喜欢滑雪跳伞和丛林冒险。
团队介绍
说起团队我非常自豪,这是一群富有好奇心而且想法丰富的天才,大家凑到一块是想做大事的。
团队以跨功能的任务驱动,这样每个人既可以专攻,又可以毫无压力的对整个架构有所贡献。我们的理念就是接触的方面越多,对团队的锻炼越大。更多关于团队的理念 见此 。
在工作组织方面,我们有着很大的自由度,当然作为一个公司组成,我们还是有季度目标的,并且鼓励敏捷开发模式。我们使用GitHub进行code review和问题跟踪,用Google Apps作为邮件、文档和表单系统。跟很多团队习惯使用Trello不同,我们是Slack和slack机器人的重度用户。
原始架构
最开始的时候,Medium部署在EC2上,用Node.js实现,后来公测的时候迁移到了 DynamoDB 。
其中有个节点用来处理图片,负责将复杂的处理工作转向 GraphicsMagick 。还有一个节点用作后台的 SQS 队列处理。
我们用 SES 处理邮件, S3 做静态元素服务器, CloudFront 做CDN, nginx 作为反向代理, Datadog 用来监控, Pagerduty 用来告警。
在线编辑器用了TinyMCE。上线之前我们已经开始使用 Closure编译器 以及部分的 Closure库 ,但是模板还是用的 Handlebars 。
当前架构
虽然Medium表面看起来很简单,但是了解其后台的复杂性后,你会大吃一惊。有人会说,这就是个博客啊,用Rails之类的一周就能搞定了。
总之,闲话不多说,我们自底向上介绍以后再做判断。
运行环境
Medium目前运行在 Amazon虚拟私有云 ,使用 Ansible 做系统管理,它支持配置文件模式,我们将文件纳入代码版本管理,这样就可以随时回滚随时掌控。
Medium的后台是个面向服务的架构,运行了大概二十几个产品服务。划分服务的依据取决于这部分功能的独立性,以及对资源的使用特性。
Medium的主体仍然是Node.js完成,方便前端和后端的代码共享,主要是文章编辑和发布这个过程。Node大部分时候不错,但阻塞 event循环的时候会有性能问题。为了缓解,我们在每台机器上启动多个Node实例,将对性能要求比较高的任务分配给专门的实例。同时我们还深入V8运 行时环境查看更加细节的耗时,基本上是JSON去串行化的时候的对象具体化耗时较多。
我们还用Go语言做了一些辅助服务。因为Go非常容易编译打包和发布。相比Java语言的冗长罗嗦和虚拟机,Go语言在类型安全方面做的很到位。就个人习惯来讲,我比较喜欢在团队内部推广强类型语言,因为这类语言能够提高项目的清晰度,不纠结。
目前静态元素大部分是通过CloudFlare提供的,还有5%通过Fastly,5%通过CloudFront,这么做是为了让两者的缓存得到 更新,用于一些紧急的情况。最近我们在应用流量上也使用了CloudFlare,当时主要是为了防止DDOS攻击,但随之而来的性能提升也是我们愿意看到 的。
我们使用Nginx和 HAProxy 做反向代理和负载均衡,来满足我们所需功能的维恩图。
我们仍然使用 Datadog 来监控, Pagerduty 来告警。现在又增加了ELK( Elasticsearch 、 Logstash 、 Kibana )来进行产品问题调试。
数据库
DynamoDB仍然是我们的主力数据库,但是用起来也不是毫无问题。目前遇到的比较棘手的是大V用户展开和虚拟event过程中的 热键问题 。我们专门在数据库前面做了一个Redis缓存集群,来缓解这些问题。到底为开发者优化还是为产品稳定性优化的问题通常会引发争执,我们也一直在尝试中和两者的矛盾。
目前我们开始在存储新数据上使用 Amazon Aurora ,它可以提供更灵活的查询和过滤功能。
我们使用 Neo4J 存储Medium网络中实体之间的关系,运行在有两个副本的主节点上。用户、文章、标签和收藏都属于图中的节点。边则是在实体创建和用户进行推荐高亮等动作时生成。我们通过在图中游走来过滤和推荐文章。
数据平台
早期我们对数据非常渴望,不断尝试数据分析框架来辅助商业和产品决策。最近我们则是利用同样的框架来反馈产品系统,支持Explore等数据驱动功能。
我们采用 Amazon Redshift 作为数据仓库,为生产工具提供可变存储和处理系统。我们持续将诸如用户和文章等核心数据从Dynamo导入Redshift,还将诸如文章被浏览被滚动等event日志从S3导入Redshift。
任务通过一个内部调度和监控工具Conduit调度。我们用了一个基于断言的调度模型,只有条件满足的时候,任务才会执行。从产品角度来讲,这是不可或缺的:数据制造方应该与数据消费方隔离,还要简化配置,保持系统的可预见和可调试性。
Redshift的SQL检索目前运行不错,但我们时不时需要读取和存储数据,所以后期增加了 Apache Spark 作为ETL,Spark具有很好的灵活性和扩展能力。随着产品的推进,估计后面Spark会成为我们数据流水线的主要工具。
我们使用 Protocol Buffers 作为schema来确保分布式系统的各层次间保持同步,包括移动应用、web服务和数据仓库等。通过定制化的选项,我们将schema标记上更加细化的配置,如带有表名和索引,以及长度等校验约束。
用户也需要保持同步,这样移动端和网页端就可以保持日志的一致性了,同时方便产品科学家们用同样的方式解析字段。我们帮助项目成员从.proto文件中生成消息、字段和文档等内容,进而利用所得数据开展研究。
图片服务器
我们的图片服务器现在用Go语言实现,采用瀑布型策略来提供处理过的图片。服务器使用 groupcache ,是memcahce的替代品,可以帮助减轻服务器之间的重复工作。而内存级缓存则是用了一个S3的持续缓存。图片的处理是请求来触发的。这给了我们的架构设计师灵活改变图片展示的自由度,为不同平台优化,而且避免了大量的生成不同尺寸图片的操作。
目前Medium对图片主要支持放缩和裁剪,但原始版本中还支持颜色清洗和锐化等操作。处理动图很痛苦,具体后续可以写一篇文章来解释。
文本标注
文本标注是个有意思的功能,用了一个小型Go服务器,跟 PhantomJS 接口形成渲染进程。
我一直想要把渲染进程换到Pango,但是在实践过程中,能在HTML中摆放图片的能力的确更灵活。而从功能的使用频率来看,这意味着更容易开发和管控。
自定义域名
我们允许用户为其Medium文章设置个性化域名。我们想做成单点登录且HTTPS全覆盖,因此实现起来颇有难度。我们专门准备了一批 HAProxy服务器用来管理证书,并向主要应用服务器引导流量。初始化一个域的时候需要一些手动的工作,但是通过与Namecheap的定制化整合,我 们将其大部分转换为自动化。证书验证和发布链接由专门服务负责。
网站前端
网页端这块,我们有自主研发的单网页应用框架,使用Closure标准库。我们使用Closure模板渲染客户端和服务端,然后使用Closure编译器来缩减代码并划分模块。编辑器是我们网页端应用最复杂的部分,具体参见 Nick 此前的文章。
iOS
我们的两个应用都是原生的,尽量避免使用网页视图。
在iOS上,我们使用了一系列的自建框架,以及系统原生组件。在网络层,我们用NSURLSession发起请求,用Mantle解析JSON并 映射到模型。我们还有一层基于NSKeyedArchiver的缓冲层。对于将条目渲染为共同主题的列表,我们有一个通用方法,这让我们能够快速为不同类 型的内容构建新列表。文章界面是一个定制布局的UICollectionView。我们使用共享组件来渲染全文界面和预览界面。
应用代码的每一次提交都会编译后推送给Medium员工,这样我们能够很快尝试新版本。应用商店的版本是滞后于新版本的,但我们也一直在尝试更快的发布,虽然可能仅仅是几处小更新。
对于测试,我们使用XCTest和OCMock。
Android
在Android方面,我们与当前的SDK和支持库版本保持一致。我们并没有使用任何复杂的框架,而是倾向于为重复出现的问题构建持续性的模式。我们利用 guava 弥补Java中所有的缺失。另一方面来讲,我们也倾向于使用第三方库来解决特别的问题。我们还利用protocol buffers定义了API,用以生成应用中的对象。
我们利用 mockito 和 robolectric 。我们会开发一些高层测试来运转activity和poke:刚添加screen或要重构的时候,先创建一些基本的版本,随着我们复现bug它们也会进 化。我们还会开发一些底层测试,来检测一个特定的类:随着新功能的增加我们会创建测试,这能够帮助我们思考和设计底层是如何交互的。
每个提交都会作为alpha版本自动推送到play商店,然后到Medium员工(包括我们的Hatch,Medium内部版)。推送大部分发生在周五,我们会把alpha版本发送给 测试小组 ,请他们用整个周末进行测试。然后,周一我们会从beta版推进至正式产品版。因为最近一批代码总是随时可以推送,因此一旦发现很严重的bug,我们就可 以立即修复正式产品版。当我们怀疑某些新功能的时候,可以给测试小组更长的时间。开发比较亢奋的时候,也可能发布地更加频繁。
AB测试及其他
我们所有的客户端都用了服务器端提供的功能标记,称为 variants ,用于A|B测试以及指导未完成功能的开发。
剩下还有一些框架相关的内容我没有提及: Algolia 让我们在搜索相关功能上快速迭代, SendGrid 处理邮件, Urban Airship 用来发送提醒, SQS 用来处理队列, Bloomd 用作布隆过滤器, PubSubHubbub 和 Superfeedr 用作提供RSS等等。
编译、测试和部署
我们积极拥抱持续集成技术,随时随地准备发布,使用 Jenkins 来负责相关事宜。
我们曾经使用Make作为编译系统,但是后来迁移到 Pants 。
测试方面我们采用单元测试和HTTP层面功能测试两者结合的方式。所有提交的代码都需要通过测试才能够合并。我们跟Box团队合作,利用 Cluster Runner 来分布式运行测试,保证效率,而且能够和GitHub很好的整合在一起。
我们大概不到15分钟就可以把某阶段的系统部署,顺利编译通过,留作正式产品的备选。主应用服务器通常一天要部署五次,多的时候十次。
我们采用蓝绿部署。正式产品版本的流量发送给一个canary实例,发布进程会监控部署过程的错误率,必要时候通过调整内部DNS回滚。
面向未来
到此,讲了足够多的干货!为了重构产品,获得更好的阅读体验,还有很长的路要走。我们仍然在努力为作者和发布者设计更多的功能。打比方来讲,线上 阅读还是一片绿地,面对它有着无限可能,我们始终抱着开放的心态设计和实现功能。未来我们会努力用各种功能为用户提供高质量内容和价值。