互联网创业核心技术:构建可伸缩的 Web 应用

最近在读这本书,发现此书很冷门但读完获益匪浅,强烈推荐,特别是初中级的后端。它通俗易懂地讲解了 Web 架构的套路。读完之后你会发现,高并发可伸缩系统听起来这么高的术语,拆解下来关键的点无非是那么几点。

我以前写 Android 应用的时候就对高并发之类的充满了敬佩之情,偶尔碰到的文章有着各种专业的术语,如负载均衡、反向代理、NoSQL 之类的,看得一头雾水。后来开发 Rails 后,对这些或多或少有了一些了解,但没有一个很清晰的整体认识。 读了这本书感觉零碎的知识有了一个完整的拼图。

《构建可伸缩的 Web 应用》写得非常接地气,读的时候我时常拿它和开发过的应用的架构做比较,发现真的是一模一样。DNS 轮询负载均衡器、Web 服务器无状态实现可伸缩、利用缓存提升性能等都是业界的最佳实践,都在书中做了易懂的说明。我觉得这本书对深度和广度的拿捏恰当好处,不会讲得很玄很方法论,让人摸不清头脑,也不会一股脑地钻进细节里,只见树木不见森林。

这本书是 16 年末出版的,里面介绍的技术都是业界常用的解决方案,翻译得也很流畅。但可惜知名度不高,我也是凑单才买的 :)。希望能有更多的人读到这本好书。

FactoryGirl 源码浅析

FactoryGirl 是我个人十分喜欢的 Gem,它能很方便地模拟测试数据。使用技巧可见 FactoryGirl 技巧。出于兴趣我研究了下它的源代码,说实话比想象得要复杂。由于 FactoryGirl 配置的灵活性以及 Ruby 本身的语言特点,使得它代码整体上比较飘逸。
这里我会对它最基础的方法进行分析,版本为 4.8.0。

FactoryGirl.define

先从定义 Factory 开始。下面的代码定义了名为 user 的 Factory,它有一个名为 name 的属性,值为 Kay。

1
2
3
4
5
FactoryGirl.define do
factory :user do
name 'Kay'
end
end

正则表达式必知必会

匹配单个字符

匹配纯文本

正则:Ben
>
Hello, my name is Ben. Please visit my website at http://www.forta.com/.

匹配任意字符

. 匹配任意单个字符。在绝大多数的正则表达式实现里,. 只能匹配除换行符以外的任何单个字符。
正则:sales.
>
sales1.xls
orders3.xls
sales2.xls
sales3.xls
apac1.xls
europe2.xls
na1.xls
na2.xls
sa1.xls

Rails 多台服务器上 js 不一致的问题

发现问题

周二发布正式的时候,发生了匪夷所思的事情。
打开发布的网页,有一定的几率操作会没有反应。但在测试的时候却从没有这个问题发生。通过调试工具查看网页,发现一半的网页会加载 application-b1a 开头的文件,而另一半会加载 application-745 开头的文件。加载 applicaton-745 时会报 404,使得操作没有反应。

这里简单地说下发布的情况。该服务会发布到 A、B 两台服务器上,nginx 接收请求并转发到 A、B 上的实例。
查看正式服务器上的文件发现,A 上存在 application-b1a 文件,B 上存在 application-745 文件。看来 nginx 会查找 服务器 A 上的文件,由于找不到 application-745 而返回 404。先把 B 上的 applicaiton-b1a 复制到 A,临时修复这个问题。

排查原因

为什么两个服务器上生成的 js 文件名会不一致?我们先简单地回顾下 Asset Pipeline 生成 js 的过程。正式环境上, Asset Pipeline 会预编译文件,生成类似于 application-908e25f4bf641868d8683022a5b62f54.js 的文件。其中 908e 这串表示摘要,是根据文件的内容生成的 MD5,当 js 文件发生改变时生成的摘要也会发生变化。

1
2
3
4
5
// 引用 js
<%= javascript_include_tag "application" %>

// 生成的 html
<script src="/assets/application-908e25f4bf641868d8683022a5b62f54.js"></script>

Rails 如何在开发模式下重新加载源代码

在 Rails development 环境下,若更改了部分代码,只需要重新发起请求,就能看到最新代码的结果,不需要重启服务器。
下文简述 Rails 是如何做到这点的。

config/development.rb,也就是 development 的环境配置文件,我们可以看到不同于其他环境的一行:

1
2
3
4
# In the development environment your application's code is reloaded on
# every request. This slows down response time but is perfect for development
# since you don't have to restart the webserver when you make code changes.
config.cache_classes = false

正如注释所说的,它会使得 Rails 在每次接收请求时都重新加载源代码。

初始化

我们先从 Rails 的初始化说起,以 Rails 4.2.6 为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
# rails/application/default_middleware_stack.rb

def build_stack
...
unless config.cache_classes
middleware.use ::ActionDispatch::Reloader, lambda { reload_dependencies? }
end
...
private
def reload_dependencies?
config.reload_classes_only_on_change != true || app.reloaders.map(&:updated?).any?
end
end

当 config.cache_classses 为 true 时,middleware 会增加 ActionDispatch::Reloader,而这正是重新加载源代码的关键。
ps: middleware 可以通过 rake middleware 来查看。

处理请求

当服务器接收到请求时,中间件 ActionDispatch::Reloader 使用回调 prepare、cleanup 来实现重载源代码。其中 prepare 在处理请求前被调用,cleanup 在处理请求后被调用。

MySQL EXPLAIN 解读

EXPLAIN 解释了 MySQL 是如何执行 SQL 语句的。使用的方法很简单,在 SQL 语句前加上 EXPLAIN 关键字就可以。
下面是一个简单的例子,测试数据在文章末尾。

1
# 例 1
mysql> EXPLAIN SELECT name FROM users WHERE id = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: users
         type: const
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: const
         rows: 1
        Extra: NULL
1 row in set (0.00 sec)

EXPLAIN 列的解释:

  • id:SELECT 标识符,下面具体分析
  • select_type: SELECT 类型,下面会具体分析
  • table: 查询所使用的表
  • type: JOIN 的类型,下面会具体分析
  • possible_keys: 可能使用的索引,但不一定会真正使用
  • key: 真正使用的索引
  • key_len: 所使用的索引长度
  • ref: 与索引比较的列
  • rows: 预估需要扫描的行数
  • Extra: 额外信息

Rails Concern 源码研究

ActiveSupport::Concern 是为了更方便地 include 模块而推出的工具类。

使用方法

首先来看下它的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 传统的 Module 引入
module M
def self.included(base)
base.extend ClassMethods
base.class_eval do
scope :disabled, -> { where(disabled: true) }
end
end

module ClassMethods
...
end
end

# 使用 Concern
module M
extend ActiveSupport::Concern

included do
scope :disabled, -> { where(disabled: true) }
end

class_methods do
...
end
end

可见,通过 included 和 class_method 两个类方法使得 Module 的写法更加清晰。
你可能有些疑问,对比传统的引入,使用 Concern 虽然更加清晰了,但没什么巨大的优点。而传统的引入也可以通过将方法分成两个 module,如 InstanceMethods、ClassMethods,来达到同样的效果。
在这个简单的例子上,确实如此。但在一些嵌套的 include 上 Concern 的优势就体现出来了。

Ruby 常量查找

对 Ruby 中常量查找只有基础的认识,使用上还是有不少疑问,比如 “::A” 的含义、为什么有时使用 “A::B” 而有时直接用 “B”。
花时间查了资料来加深对此的理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module Record
def self.method
puts 'outer'
end

module Music

Record.method # outer

module Record
def self.method
puts 'inner'
end
end

Record.method # inner
::Record.method # outer
Music::Record.method # inner
end
end

我们从简单的例子出发。在上文的代码中,共存在着两个名为 Record 的 module。一个是在最外层,一个是在 Music 内部。
当第 9 行调用 Record.method 时,由于 Music 内的 Record 还未被定义,因此调用的只能是最外层的 Record module。
而在第 17 行,当我们重新调用时,却发现调用的已经是内部的 Record 了。
可见常量的调用时是有一个先后顺序的,简单的说就是从近及远,先使用近的。虽然我们从直观上很容易理解近的含义,但严格上的顺序是通过 Module.nesting 得到的。在 Music 内部调用得到 [Record::Music, Record],可见 Record::Music 确实比 Record 有更高的优先级。

mac 上 MySQL system error 32 解决方案

不知为何,在升级了 macOS 系统之后,Rails 连接 MySQL 经常性会报 Mysql2::Error: Lost connection to MySQL server at 'sending authentication information', system error: 32 的错误。重启 MySQL 就好了,过段时间又会报错,烦不胜烦。下定决心解决下。

参考了这个帖子后,大致看到有两种解决方法。一种是调大系统 ulimit -n 的值,另一种是调小 table_open_cache 的值。
从帖子的回复以及 ulimit -n 表示系统的文件句柄限制来看,个人认为调节 ulimit -n 的值是一个治标不治本的方法。本机的值已经 4k+,应该足够了。

正巧有次出现问题时,MySQL 还连接着。查了下当时的 status,发现 open_tables 不过几十,而 open_files 却几千。
执行 flush tables 清除缓存后就可以重新连接了。确实和 table_open_cache 有一定的关系。

查看了下 table_open_cache 的值,默认值是 2000。通过在 my.cnf 中添加 table_open_cache = 500,MySQL 这几天就没有闹别扭,希望能一直安稳下去。

MySQL 相关命令

1
show status like '%open_files%'; # 查看 open_files 的值,open_tables 同理
show variables like '%table_open_cache%'; # 查看 table_open_cache 的值

参考

ActiveRecord select vs pluck

selectpluck 都可以从数据库读取指定的字段,但两者存在不小的差别。

1
2
3
4
5
6
7
Product.select(:id).to_a
# Product Load (0.5ms) SELECT `products`.`id` FROM `products`
# [#<Product id: 2>, #<Product id: 1>]

Product.pluck(:id)
# Product Load (0.4ms) SELECT `products`.`id` FROM `products`
# [2, 1]

select 返回的是仅含有 id 的 Product Model 数组,而 pluck 返回的是 id 的数组。
两者相比较,pluck 省却了构造 ActiveRecord 的过程,效率更优。我们可以通过 Benchmark.measure 来验证下。

1
2
puts Benchmark.measure {Product.select(:id).to_a}
puts Benchmark.measure {Product.pluck(:id)}

- user CPU time system CPU time sum CPU time elapsed real time
select 0.050000 0.020000 0.070000 0.095440
pluck 0.000000 0.000000 0.000000 0.001845

除此之外,两者还有一个区别,即查询时机的不同。

1
2
3
4
5
6
ProductOrder.where.not(id: SubOrder.where(sub_order_no: '001').pluck(:order_id))
# SELECT `orders`.* FROM `orders` WHERE `orders`.`type` IN ('ProductOrder') AND (`orders`.`id` NOT IN (SELECT `sub_orders`.`order_id` FROM `sub_orders` WHERE `sub_orders`.`sub_order_no` = '001'))

ProductOrder.where.not(id: SubOrder.where(sub_order_no: '001').pluck(:order_id))
# SELECT `sub_orders`.`order_id` FROM `sub_orders` WHERE `sub_orders`.`sub_order_no` = '001'
# SELECT `orders`.* FROM `orders` WHERE `orders`.`type` IN ('ProductOrder') AND (`orders`.`id` != 3)

在上面这个例子中,通过 pluck 的调用进行了两次查询,而 select 只进行了一次查询。可见调用 pluck 会立即进行数据库查询。

参考