Freedom

淡泊以明志,宁静以致远。

如何设计与实现一个分布式索引框架(一):概览

这是一个系列文章,大部分内容都来自我过去在小红书发现 Feed 团队工作期间的实践和经验。在介绍的过程中我会尽量不掺杂过多的业务细节1,而专注于这背后我个人一些浅薄的设计思想,希望你在阅读完这些文章以后能够直接或者间接地拓展到不同的场景。

在介绍什么是索引框架之前先了解一下我们当时面临的业务场景2,业界现在的 feed 流产品已经逐步从非个性化全面过渡到个性化,所谓的个性化 feed 其实就是基于机器学习的推荐系统

A Little Throught About Microservices

知乎在 4 年前已经开始尝试服务化,至今也经历了好几个架构的变迁演化。我大约是 2013 年开始在知乎负责服务化的工作,对服务化的理解也从最初的模糊逐渐变得清晰,前段时间看了一篇叫做 Microservices – Not A Free Lunch! 的文章,也想趁着这个机会梳理总结目前为止我的一些感悟和想法。

香港帆船培训记录

曾经对香港的印象就是便宜的苹果电脑和遍地的茶餐厅,竟忘记了这是一个靠海的岛屿。作为一个在西部长大的孩子,对于海总是有很多憧憬。从小到大见过很多地方的海,有浑浊的,有碧蓝的,有挤满游客的,也有波涛汹涌的。其实海不一定就是蓝色的,只是人们习惯性地把自己的愿望加诸在别的东西身上,所以如果某一天你见到了不是蓝色的海,请不要抱怨它。

Auto Open Browser After Copy URL

前段时间看过一篇叫 Automate Everyday Tasks 的博客,其中的一些见解很有意思,我们日常工作中有很多细小但是重复的事情,如果能够将某些工作自动完成,会让生活更加舒适。我很喜欢 Mac 上一个叫 PopClip 的小 app,可以大大减少很多重复的操作。这篇博客就是介绍如何制作一个 app,当复制 URL 时自动在浏览器中打开。

流浪汉,木偶和厨子

最近要为 Phabricator 搭建虚拟测试环境,Vagrant 是一个不错的选择(话说官网现在更新以后,变得颇为华丽)。Vagrant 官方只提供 Ubuntu 的 base box,不过 Vagrantbox.es 有提供很多其它的系统,甚至还有 Window$。也可以自己根据官方文档重新搭建一个 base box。

Provisioning 是 Vagrant 一个很棒的特性,可以通过工具来自动配置和管理虚拟机。目前支持的有 Puppet 和 Chef,这两个都是著名的配置管理工具,其中 Google、Twitter、GitHub 在用 Puppet,Facebook 在用 Chef,知乎目前用的是 Puppet。正好这次两个都了解了一点,可以简单比较一下。

从安装方式来说,因为都是基于 Ruby 的工具,所以都可以通过 gem 来安装,从这一点上来说还是很方便的(话说对于 Mac 用户,千万别用官方提供的烂方法)。Puppet 的命令行工具就叫 puppet,而 Chef 的叫做 knife,这倒是跟 Chef 本身名字很搭。初学工具,肯定要看官方文档,在这一点上我觉得 Puppet 做得更好,至少还有一个像模像样的 Learning Puppet 系列,由浅入深,循序渐进,基本上看完就可以对 Puppet 有个大概的了解和使用。而 Chef 就只扔给你一个不知道该从哪看起的页面,作为初学者表示很难入门。

Puppet 可以将一系列的配置文件打包成一个 module 供人下载,Chef 对应的则叫做 cookbook,这两者都提供了网站用于集中放置社区贡献的包,分别是 Puppet ForgeOpscode Community(不得不吐槽,这两个网站都很糙)。对于 module、cookbook 的安装及管理 Chef 略胜一筹,Puppet 的命令行工具可以很方便地安装 module,但是如果需要安装的包比较多,就只能通过自己写脚本来自动处理。而 Chef 有一个很好用的工具 Librarian-Chef,只需要定义好所有依赖包,并放到 Cheffile 中,就可以通过 librarian-chef 命令来安装和管理。

Puppet 在易用性,社区质量和包的扩展性上来说要比 Chef 略优,能查到的文档资料也更多一点,最终我选择了 Puppet,这里是我的适用于 Phabricator 的配置文件,对 Chef 有兴趣的同学也可以看这个示例配置。

Little Tips: Redis MONITOR Command

前段时间知乎的 cache 服务器中的某个数据总是错乱,想到了几个可能修改缓存的源头,同时在代码中搜索相关代码,把这些服务都重启了。但是问题依旧,只是没有之前那么严重。好吧,这下肯定是某个不知名的地方仍然在访问缓存。那就从根源查起,猛然发现 Redis 的 MONITOR 命令,可以实时打印出此时正在执行的命令,正合我意,修改缓存的命令我是知道的,只需要监测这个命令,然后就可以查到来源了。

$ redis-cli monitor | grep '"set" "alist"'

给未来的你

孩子,你的一生会遇见很多不一样的人,在陌生的城市和环境里结交着朋友,找寻着恋人。你每天都会很忙,忙到没有时间喝水,没有时间吃饭,没有时间思考。你会羡慕那些生活得悠闲自在的人,仿佛他们生来如此。看到街上的情侣你也会想她是否也在想着你,因为你们仰望着同一片星空。你向往着有一天和她一起生活,你们想要的生活。

孩子,还记得我讲过的怎样遇见你母亲的故事吗?那是一个明媚的午后,记忆中的阳光总是很灿烂。当那个女生出现时,时间仿佛凝固,她没有注意到你,你知道这是一个需要你用一生去爱的女人。是的,一生。年轻人总是有无尽的诺言,但是诺言是沉重的,兑现诺言的过程是洗礼,也是炼狱,你们虽然彼此伤害,却靠得更近。

我对你的爷爷奶奶知之甚少,大部分是从旁人那里听说。他们小学是一个学校的,奶奶上学会经过爷爷的屋前。后来奶奶高中毕业后就开始教书,而爷爷则继续深造师范学校,传说他们从这时便已经在谈恋爱,分隔两地免不了很多的思念与痛苦,爷爷常常笑着说当年可是拒绝了很多女生的诱惑。爷爷毕业后回到了奶奶教书的学校,多年的长跑也终于有了结果。其实你还有一个姑姑,不过连我也没有见过。她是爷爷奶奶的第一个小孩,听人说长得很乖巧,但在十几岁时便由于生病去世了。爷爷奶奶教了一辈子学生,却不怎么跟我说起他们的故事,也许是不知如何表达。

我们都会老去,我们也曾年轻,你的困惑就是我们曾经的困惑,你的烦恼就是我们曾经的烦恼。如果你想倾诉,别忘了在远方还有你的母亲,还有我,不管发生什么,我们永远都是你最亲的人。我知道你曾经也恨过我们,但那不是真正的恨,我相信有那么一天我们能彼此释然。

到那时,你会了解,我们是如此深深地爱着你。

使用 Bootstrap 的几个问题

Responsive 与 Modal

在开启 responsive 后,小屏幕设备上显示 modal 时会变成一闪而过,然后浮动窗口就不见了。具体效果可以缩小浏览器尺寸,在这个页面的 Live demo 点击「Launch demo modal」看到。Issue #2130 专门讨论了这个问题,目前比较好的解决办法是使用这个插件,根据页面大小来动态调整 modal 的位置,不过貌似用了之后 modal 那个由上至下显示的动画就没有了。这个 issue 现在还处于开启状态,看来官方短期内是不会解决这个问题的。

Responsive 与 Navbar

responsive 模式下的 navbar 显示效果很赞,但是有一个很令人费解的事情,默认情况下所有 dropdown menu 都是展开的,对于使用多个菜单项,且子菜单条目很多的场景这是不能接受的。于是 Issue #3184 出现了,这次的方案比较 hack,需要修改 bootstrap-responsive.css,将 .nav-collapse .dropdown-menu 里的 display: block; 注释掉。这时你会惊喜地发现 dropdown menu 默认折叠了,点击也能展开子菜单。 最新版 Bootstrap 已经修复了 dropdown menu 默认展开的问题,但是(总是有很多但是),在触屏设备上子菜单是选不中的。托 filod 同学的福,修改 bootstrap-dropdown.js 中的一段代码:

1
2
3
4
5
6
7
8
/* APPLY TO STANDARD DROPDOWN ELEMENTS
 * =================================== */

$(document)
  .on('click.dropdown.data-api touchstart.dropdown.data-api', clearMenus)
  .on('click.dropdown touchstart.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() })
  .on('click.dropdown.data-api touchstart.dropdown.data-api'  , toggle, Dropdown.prototype.toggle)
  .on('keydown.dropdown.data-api touchstart.dropdown.data-api', toggle + ', [role=menu]' , Dropdown.prototype.keydown)

这里同时监听了 click 和 touchstart 事件,于是在触屏设备上先有 touchstart 将子菜单隐藏,再有 click 点击到隐藏后该位置的菜单项,因此你永远都不可能点到想点的子菜单。根本原因也是因为我们之前注释了 display: block; 引起,改变了 Bootstrap 的使用场景,于是 JS 出现如此纰漏。解决方法便是不监听 touchstart 事件,虽然会造成些小问题,不过也算基本满足要求。这个 issue 官方明确表示不会采纳,不过还是希望以后有机会增加一个开关选项给用户。 关于这个问题的讨论可以看 Issue #4550,不明白为什么官方一直不解决,我的修改可以见这个这个 commit。

理解 tornado.gen

Tornado 通过 @asynchronous decorator 来实现异步请求,但使用的时候必须将 request handler 和 callback 分离开,tornado.gen 模块可以帮助我们在一个函数里完成这两个工作。下面是官方的一个例子:

1
2
3
4
5
6
7
8
class GenAsyncHandler(RequestHandler):
    @asynchronous
    @gen.engine
    def get(self):
        http_client = AsyncHTTPClient()
        response = yield gen.Task(http_client.fetch, "http://example.com")
        do_something_with_response(response)
        self.render("template.html")

这里用到了两个 decorator 稍显复杂,第一个 @asynchronous 会首先被执行,它的主要工作就是将 RequestHandler_auto_finish 属性置为 false,如下:

web.pydownload
1
2
3
4
5
6
7
8
9
10
def asynchronous(method):
    @functools.wraps(method)
    def wrapper(self, *args, **kwargs):
        if self.application._wsgi:
            raise Exception("@asynchronous is not supported for WSGI apps")
        self._auto_finish = False
        with stack_context.ExceptionStackContext(
            self._stack_context_handle_exception):
            return method(self, *args, **kwargs)
    return wrapper

接着就是最重要的 @gen.engine,这里充分利用了 generator 的各种特性,首先来看 @gen.engine 的实现(我删减了部分代码以简化理解):

gen.pydownload
1
2
3
4
5
6
7
8
9
def engine(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        gen = func(*args, **kwargs)
        if isinstance(gen, types.GeneratorType):
            runner = Runner(gen)
            runner.run()
            return
    return wrapper

局部变量 gen 代表第一段代码里的 get 函数,因为 get 包含了 yield 语句,因此成为了一个 generator。注意这里 get 并没有被执行,只是赋给了 gen。接下来是运行 Runner 对象的 run 函数。在理解 run 之前需要知道 generator 是通过调用 next() 或者 send() 来启动,启动之后会在遇到 yield 的地方 hold 住,然后将 yield 后面的语句的返回值返回给调用者,generator 此时即处于暂停运行状态,所有上下文都会保存。再次调用 next()send() 便会恢复 generator 的运行,如果不再遇到 yield 语句就会抛出 StopIteration 异常。在恢复运行的同时 yield 语句本身会有返回值,如果是通过调用 next() 来恢复的,那么返回值永远是 None,而如果是通过 send() 则返回值取决于传给 send() 的参数。更多关于 generator 的说明请参考官方文档

结合第一段的示例代码,可以想到 run 干的工作可能就是启动 generator,然后获得 gen.Task 对象并调用 http_client.fetch 函数,等回调回来之后恢复 generator 的运行,最后将回调的返回值通过 send() 赋给 response。下面是我简化后的代码。

gen.pydownload
1
2
3
4
5
6
7
8
9
10
11
12
def run(self):
    while True:
        if not self.yield_point.is_ready():
            return
        next = self.yield_point.get_result()
        try:
            yielded = self.gen.send(next)
        except StopIteration:
            return
        if isinstance(yielded, YieldPoint):
            self.yield_point = yielded
            self.yield_point.start(self)

第 3 行检查回调是否完成,第一次运行 run 总是会返回 True。第 5 行获取回调的返回值,同样的第一次运行返回的是 None。将 None 传给 send() 启动 generator,yielded 即是 gen.Task 对象,第 12 行调用 start 开始运行我们真正需要运行的函数,对应到示例代码就是 http_client.fetch 函数,同时将 Runnerresult_callback 作为回调函数。如下:

gen.pydownload
1
2
3
4
5
6
7
8
9
10
11
def result_callback(self, key):
    def inner(*args, **kwargs):
        if kwargs or len(args) > 1:
            result = Arguments(args, kwargs)
        elif args:
            result = args[0]
        else:
            result = None
        self.results[key] = result
        self.run()
    return inner

在得到回调返回值之后再次调用 run,通过 get_result 获取返回值,最后将返回值返回赋给 response,继续 request handler 的代码流程。