准备将 Doodle 移植到 Linux
2013 5 19 11:59 PM 3829次查看
于是只能很抱歉地告诉 Doodle 的使用者们,Doodle 2 将只能在 Linux 平台(VPS 或独立服务器)上运行了,老版本将只在即日起的一年内提供迁移(到 Doodle 2 或 WordPress)、修正 bug 和指导性的帮助,不再添加新功能了。
预计第一步将支持的是 Ubuntu 12.04+,因为开发是在 OS X 10.8 上,所以肯定也是支持的,只是依赖的工具 / 包 / 库我也记不清(找不到干净的系统)。Windows 上的开发者就抱歉了,支持这个实在头大。
虽说计划是最低支持 128 MB 内存的 VPS,不过看到 DigitalOcean 512 MB + 20G SSD 的 VPS 才卖 $5 每月,也支持按小时扣费,网速和口碑好像还行,所以想先玩玩。有闲钱赞助的,可以通过这个链接购买,累计消费 $10 (例如购买 2 个月最低配置的 VPS)后,我会收到 $10 的佣金,先谢了。发现几个优惠码,不知何时失效,有需要的自便:SSDBEAR10(免费 $10,需要绑定信用卡或 PayPal)、HELLOSF(免费 $5,无需绑定)。不会用的可以在填写支付方式时,找到 Promo Code,把优惠码贴进去,成功就会提示。
接下来是我临时想到的一些替代的解决方案,可能会有变化:
- 开发语言:Python 2.7.x。
2.6 及以下因缺少某些语法和标准库,写起来不爽;3.x 各种不兼容,你懂的。
至于 JavaScript 和 Bash 之类的就不提了。 - Web 框架:Tornado 3.x。
Tornado 我用得比较熟,异步虽然大部分情况下没用,但 AsyncHTTPClient 很有用(前天刚写了个工具,需要并发访问几百个 URL 来做统计,几秒钟就全部返回了)。比较头疼的是 StaticFileHandler,规则很奇葩,有空得自己重写下,或者交给 Nginx。
以前用的 YUI 只适合 GAE。Django 不会用,太复杂。Flask 的 routing 不喜欢,开发 YUI 时就说过了。Bottle 和 web.py 的性能确实赶不上 Tornado(自测,场景并不适用于所有情况,例如阻塞严重的情况)。 - 数据库:Redis 2.6+。
你没看错。最近拿 Redis 做了个目标千万用户的应用,感觉一台服务器就绰绰有余了。
开发起来很有挑战性和新鲜感,比用 SQLAlchemy + MySQL 更舒服,虽说大部分时间花在了实现 ORM 上。
至于内存占用,如果不算复杂的功能,预计本站现有的数据只需要不到 50 MB 的内存(也许多算了一倍),比 MySQL 也没差,还不需要缓存。
缺点有: ORM 和事务的结合比较难做,复杂的关系和查询不好实现,需要写出详细的实现文档。不过实现一个博客系统,好像也没什么困难,后面会提到如何实现。 - 安装:Buildout。
很容易设置依赖,还自带 virtualenv 功能。
不过有些 C 库 / 工具还是得手动安装和配置,小白用户也得自己动手了。 - 部署:Fabric + Git + Supervisor + Nginx。
Gunicorn 虽然用起来更简单,但如果要对 Tornado 的 HTTPServer 进行一些自定义设置,貌似比较难弄。 - 计划任务:crontab。
好像也就干些备份数据库的事,好在 Redis 的备份很简单,复制下数据文件即可。 - 任务队列:RQ、Beanstalkd,或者自己拿 Redis 发明轮子吧。
不过大部分情况都能用 Tornado 的 IOLoop.add_timeout(),所以有需要再去调研吧。 - 发送邮件:smtplib 模拟登录,或者 Amazon SES。
感觉自己搭建邮件服务器太难维护,且容易被当成垃圾邮件。 - 错误日志:Sentry。
之前拿 Sentry + SQLite 来查错误日志,感觉很卡。翻了下文档发现支持 Redis,准备试试。小内存的可以不启用,直接使用文件记录吧。 - 用户认证:Google OAuth 2.0。
新浪微博和 Twitter 拿不到邮箱,对我来说没用。腾讯微博注册开发者还要验证,我就懒得弄了。
其实拿不到邮箱也能做,但会把用户系统搞得很复杂,而且回复时还不能邮件通知。
计划增加的功能:
- 支持 Markdown。
虽说自己不用,不过既然这么流行,还是支持下吧…… - 支持全文搜索。
基本想法是用结巴分词提取关键词,存入 sorted set,关键词作为 key,出现频率作为 score,文章 ID 作为 member。标题中的关键字和分类、标签名可以作为较大的权重。还得允许手动设置关键词,并作为一个很大的权重。
为了避免占用内存过多,还需要设置 stop words;限制每篇文章的关键词个数和关键词的文章数好像也有必要。
当然结巴分词本身就很占内存,所以应该做成一个独立的服务或脚本,甚至调用 HTTP 接口。
至于英文分词,还没想好用啥,反正比中文简单。 - 提供 REST API。
其实不是为了外部调用,主要是想把网页的主体部分做成用户无关的,方便 CDN 进行缓存,然后再用 AJAX 加载用户相关的部分。 - 支持 archive 页面。
其实不加也一样,主要有些搜索引擎老去尝试访问,看错误日志看得烦。 - 支持按页数分页。
只是为了看上去美观些……
数据库设计:
这里主要考虑可行性,太细节的就忽略了。我也假设你已清楚老版本 Doodle 的数据结构,否则请参考源码。
- MaxID:存储在 hash 里,field 为类型名,value 为该类型的最大 ID。可以用 HINCRBY MaxID kind 1 拿到下一个 ID。
- Article:存储在 hash 里,field 为 ID,value 用 JSON 编码。
- ArticleURL:存储在 hash 里,field 为 URL,value 为 ID。允许给一篇文章设置多个相对链接。
- ArticleHitCount:存储在 hash 里,field 为 ID,value 为 count。
- PublicArticlePublishTime / PrivateArticlePublishTime:存储在 sorted set 里,score 为文章发布时间(按当地时间存储为 %Y%m%d%H%M%S),member 为文章 ID。
按十篇文章一页来算,第五页可以这样获取:ZREVRANGEBYSCORE ArticlePublishTime +inf 0 LIMIT 40 10。
要获取 2013 年 5 月的所有文章可以这样:ZREVRANGEBYSCORE ArticlePublishTime 20130500000000 (20130600000000。
要获取这篇文章的前一篇文章可以这样:ZREVRANGEBYSCORE ArticlePublishTime (20130415043212 0 LIMIT 0 1。
而下一篇文章则可以这样获取:ZRANGEBYSCORE ArticlePublishTime (20130415043212 +inf LIMIT 0 1。
要删除或隐藏一篇文章,可以用:ZREM ArticlePublishTime article_id。
如果由于文章修改了 URL,而导致老的 URL 在 ArticleURL 中找不到,但日期没变,且每天不会发布多于一篇的文章的话,还能重定向到那天的第一篇文章去,避免 404。
缺点:这种方式只能精确到秒,不过正常人不会在一秒内发两篇文章吧;而且貌似精确到分,去掉世纪都行(反正我没打算写到 2100 年)。此外,用 timestamp 也可节约空间,还不用担心时区的问题,不过计算起来稍微复杂些。 - ArticleUpdateTime:存储同上。但因为只提供给 feed,只需要最新的 10 篇文章,所以保存时还需要删除多余的:ZREMRANGEBYRANK ArticleUpdateTime 10 -1。
- Comment:同 Article。不过老版本的 Doodle 里,评论的 ID 不是全局唯一的,迁移过来时需要转换成唯一的。可以按时间顺序生成,记下对应关系,然后替换评论引用中的 ID。
- CommentsOfArticle:存储在 list 里,key 为 CommentsOfArticle:article_id,element 为评论 ID。
还可以直接把评论作为 element 存储到 CommentsOfArticle 里,这样连 Comment 类都不需要了。但删除评论时就不好操作了,而前者可这样删除:LREM CommentsOfArticle:article_id 0 comment_id。
按十条评论一页来算,第五页可以这样获取:LRANGE CommentsOfArticle:article_id 40 49。 - LatestComments:存储在 list 里,element 为评论 ID。因为只提供给 feed,只需要最新的 10 条评论,所以保存时还需要删除多余的:LTRIM LatestComments 0 9。
- Category:存储在 sorted set 里,key 为 Category:[parent_path:]category_name,score 为文章发布时间,member 为文章 ID。只存储公开的文章即可。
要查询一个分类的路径,可以用:EXISTS Category:category_name;查不到时再尝试:KEYS Category:*:category_name。
要查询一个分类的子类,可以用:KEYS Category:category_path:*。
要查询一个分类及其子类的文章,可以先查出它及其子类的 key,然后合并结果集:ZUNIONSTORE ArticlesOfCategory:category_name categories_count category_key1 [category_key2 ...] AGGREGATE MAX。这个值可以保留着当做缓存,需要更新时再重新生成。
要查询一个分类的父类,可以用:KEYS Category:*:category_name。或者拿到其路径,然后 split(':') 即可。
要修改一篇文章的分类,可以用:ZREM Category:old_category_path article_id 和 ZADD Category:new_category_path article_id。
和老版本的 Doodle 的区别在于,之前的分类路径用逗号隔开,现在要用冒号。
因为 KEYS 命令是 O(N) 的,所以似乎缓存一下比较好。如果当前 DB 里 key 比较多的话(目前看来,正常的博客只有 Subscriber 会占用大量 keys),可以放在单独的 DB 里(遇到性能问题,再用 MOVE 命令移动也可);或者在 hash 中保存 name 和 path 的对应关系,然后在应用程序里遍历匹配。还可考虑用一个 hash 或多个 set 存储分类的关系,占用的内存稍多,查询需要递归,修改、合并和移动分类时操作很复杂,且由于访问的 key 会多一倍,性能不一定好。 - CategoryPath:存储在 hash 里,field 为 name,value 为 path。避免查询分类路径时的开销。
- ArticlesOfCategory:存储在 sorted set 里,key 为 ArticlesOfCategory:category_name,score 为文章发布时间,member 为文章 ID。
- Tag:存储在 sorted set 里,key 为 Tag:tag_name,score 为文章发布时间,member 为文章 ID。
获取 Tag 的文章数可以用 ZCARD 命令,批量获取时可以用 pipeline。 - TagCount:存储在 hash 里,field 为 tag name,value 为 count。一来用于缓存文章数,二来避免用 KEYS 获取所有标签名(可以用 HKEYS 替代,N 会小很多,也没有匹配的开销。)
- User:存储在 hash 里,field 为 email,value 用 JSON 编码。
- Subscriber:存储在 string 里,key 为 Subscriber:user_agent[:ip],value 为 count,过期时间为一天后。
获取当天的订阅者可以用:KEYS Subscriber:* 和 MGET subscriber_key1 [subscriber_key2 ...],然后 sum 一下返回值即可。这个值最好缓存下,过期时间一小时以上亦可。
因为 KEYS 命令是 O(N) 的,长度还不定,所以可能会被攻击。这种情况应该屏蔽攻击者,并限制 key 的数量。单独放在一个 DB 里也能提高性能,因为无需匹配 key,获取所有的即可。
还可以搭配一个 sorted set,score 为过期时间,member 为 user_agent[:ip]。可以很容易地找出过期的订阅者,删掉并计算未过期的。不过因为有缓存,查询次数一般远小于写入次数,而 ZADD 也并不是 O(1) 的操作,所以我倾向于使用第一种实现。
不过奉劝一句:别滥用 Redis。复杂的关系还是用关系数据库吧,不然处理数据时很绕;虽说即使用关系数据库还是很难懂……
嗯,等我有空再填坑吧。我需要先休息下,感觉这一个多月的开发让我的大脑透支了。
下周(按西方的习惯应该是这周)知乎会发布一个新产品,大家敬请期待吧~
向下滚动可载入更多评论,或者点这里禁止自动加载。