后台任务 vs 消息队列 -「译」

后台任务 VS 消息队列

一件常见的事情,我看到工程师们认为消息队列跟后台任务是相等的。这是他们所忽略的:消息队列是一个后台任务的超集。所有的信息进程都在后台完成,但是后台任务并没有通过消息队列来完成。

拿一个简单的使用例子:当一个用户注册时,我想要去发一条欢迎 email,正常你会想在后台发送这封 Email,因此不影响用户的使用体验,但你真的需要去安装 ActiveMQ,RabbitMQ 或者 Resque 去做这些吗?显然不需要。

在建造一个复杂的系统时消息队列是一个基本的建筑模式。你的许多系统组件也许被不同团队所编写,但是他们用队列实现消息发送来通信,一个组件可以发送消息给另外一个组件说:“请发送这封邮件”。但是消息队列系统有它们的代价:它们是复杂的,因为他们被设计成你的分发系统的地基,他们必须被部署和监控,他们必须是可靠并且高可用的。

很多人安装消息队列去运行简单的后台程序,我认为并不需要这么复杂,我有一个简单的问题:“我是在让两个不同的子系统进行通信还是仅仅在衍生相同的工作?”几乎每一个网站都会立即面临注册邮件案例。想想在用户的浏览器投票结果中,执行一些操作可能需要30-60秒的时间,衍生出单独的线程去运行这些工程是完全充足且太过于简单的,这也是我 girl_friday 项目背后的原因,我想要一个简单并且可靠的方式去运行后台任务,而不需要复杂的消息队列系统。

总结

总的来说, 简单的任务用 sidekiq 等后台任务来处理即可。

MQ 适用于比较大型的,异构系统,多个系统间的相互通信。

MQ 是 sidekiq 的超集。

Sidekiq 背后的原理是将任务都放入队列,然后用多个线程去运行任务。

Sidekiq 使用场景

比如发邮件,完成一些耗时的任务等。

代码大概是:

1
2
3
4
5
6
7
8
9
# app/workers/hard_worker.rb
class HardWorker
  include Sidekiq::Worker
  
  # 这里系统会不断将任务压入队列,然后启动线程去执行任务
  def perform(name, count)
    puts 'Doing hard work'
  end
end

MQ 使用场景

将一堆任务压入消息队列,然后让其他系统取出任务进行实现。

虽然我也可以将「发邮件」压入消息队列,然后在系统内取出进行发邮件,不过总归大材小用

代码大概是:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 发布者

class Publisher
  # In order to publish message we need a exchange name.
  # Note that RabbitMQ does not care about the payload -
  # we will be using JSON-encoded strings
  def self.publish(exchange, message = {})
    # grab the fanout exchange
    x = channel.fanout("blog.#{exchange}")
    # and simply publish message
    x.publish(message.to_json)
  end

  def self.channel
    @channel ||= connection.create_channel
  end

  # We are using default settings here
  # The `Bunny.new(...)` is a place to
  # put any specific RabbitMQ settings
  # like host or port
  def self.connection
    @connection ||= Bunny.new.tap do |c|
      c.start
    end
  end
end

# 消费者

# dashboard/app/workers/posts_worker.rb
class PostsWorker
  include Sneakers::Worker
  # This worker will connect to "dashboard.posts" queue
  # env is set to nil since by default the actuall queue name would be
  # "dashboard.posts_development"
  from_queue "dashboard.posts", env: nil

  # work method receives message payload in raw format
  # in our case it is JSON encoded string
  # which we can pass to RecentPosts service without
  # changes
  def work(raw_post)
    RecentPosts.push(raw_post)
    ack! # we need to let queue know that message was received
  end
end

然后还有 Exchange queue 绑定啊之类的

相信看完代码会对两者的区别更清晰点。

Dalli -「译」

Dalli

Dalli 对于 memcached 服务是一个高性能的纯Ruby 客户端,它工作于 1.4+ 版本的 memcached,因为它使用了较新的二进制协议。它被认为是 memcache-client gem 的替代品。

这个名字是一位葡萄牙画家的有名画作的变种。

Dalli的最初版本是 CouchBase 赞助的,非常感谢他!

设计

在维护 memcache-client 两年后,因为一些特殊的原因我决定去写 Dalli。

  1. 代码太过老旧以及粗糙,大部分的代码是单独的 1000 行 .rb 文件
  2. 它的许多参数都很少被使用并且复杂化了代码库
  3. 实施监控钩子的时候没有单独的店
  4. 使用太旧的文本协议,降低了未处理的性能

所以有一些笔记,Dailli:

  1. 使用确切相同的算法去选择一个服务,因此存在的 memcached集群运行起 TB级的数据跟 memcache-client 一样
  2. 在 Ruby 1.9.2 中大约比 memcache-client 快百分之20
  3. 包含详细的“阻塞点”方法,能够处理所有请求;这些通过检测工具能够被 hook 来追踪 memcached的使用情况
  4. 在管理环境下支持 SASL
  5. 提供正确的故障切换和可调控的超时

支持 Ruby 版本

Dalli应该工作于:

  1. JRuby 1.6+
  2. Ruby 1.9.3+
  3. Rubinius 2.0

如果你有问题,请提 issue

安装和使用

记住,Dalli要求 memcached 1.4+,你可以通过 memcached -h 来检查版本。请注意 memcached 1.2.8 版本和 Mac OS X Snow Leopadrd 不兼容。通过 Homebrew 来安装 memacached 1.4.x

1
brew install memcached

在 Ubuntu 上你可以通过运行以下命令来安装:

1
apt-get install memcached

你可以使用下面这段代码来确认安装情况

1
gem install dalli
1
2
3
4
5
require 'dalli'
options = { :namespace => "app_v1", :compress => true }
dc = Dalli::Client.new('localhost:11211', options)
dc.set('abc', 123)
value = dc.get('abc')

这测试套件要求 memcached 1.4.3+ 和激活的 SASL。Dalli 没有运行时依赖并且以后也不会有。你可以自定义安装 ‘kgio’ gem 去给 Dalli 提升 百分之20到30的运行提升。

在 Rails3.X 和 Rails4.X 的使用

在你的 gemfile:

1
gem 'dalli'

config/environments/production.rb:

1
config.cache_store = :dalli_store

这里是一个更全面的例子,在这个 Rails App 中,设置了一天内的最大缓存,压缩过大的数据和对所有记录进行命名空间规划,如果你有多个 APP 并且要共享缓存的值,可以移除命名空间。

1
2
config.cache_store = :dalli_store, 'cache-1.example.com', 'cache-2.example.com',
  { :namespace => NAME_OF_RAILS_APP, :expires_in => 1.day, :compress => true }

如果你的服务器在ENV["MEMCACHE_SERVERS"]有特别的设置(比如在 Heroku使用第三方缓存服务时),在服务器中简单的提供nil

1
config.cache_store = :dalli_store, nil, { :namespace => NAME_OF_RAILS_APP, :expires_in => 1.day, :compress => true }

使用 Dalli 为了20分钟后 Rails 的 session 存储过期, 在config/initializers/session_store.rb中:

针对 Rails >= 3.2.4 :

 Rails.application.config.session_store ActionDispatch::Session::CacheStore, :expire_after => 20.minutes

针对 Rails 3.X :

1
2
require 'action_dispatch/middleware/session/dalli_store'
Rails.application.config.session_store :dalli_store, :memcache_server => ['host1', 'host2'], :namespace => 'sessions', :key => '_foundation_session', :expire_after => 20.minutes

Dalli 不支持 Rails 2.X。

Rails 和多线程

如果你使用 Puma 或者其他的多线程 APP SERVER,在 Dalli 2.7 中,你可以使用Dalli 客户端池,并且确认 Rails.cache 并不会变成多线程的资源。你必须添加 gem 'connection_pool 到你的 gemfile 中,以及加上 :pool_sizedalli_store 配置中:

1
config.cache_store = :dalli_store, 'cache-1.example.com', { :pool_size => 5 }

之后你可以像平常一样使用 Rails 的 cache,并且 Rails.cache 会使用连接池,或者你可以检验 Dalli 客户端指向:

1
2
3
4
5
6
7
8
Rails.cache.fetch('foo', :expires_in => 300) do
  'bar'
end

Rails.cache.dalli.with do |client|
  # client is a Dalli::Client instance which you can
  # use ONLY within this block
end

配置

Dalli:Client接受以下的参数,所有的时间都在几秒内。

expires_in : 全局默认 TTL 的 key 为 0,意味着不过期

namespace : 如果有规定,预先考虑每一个key和它的值提供一个简单的命名空间,默认是 nil

failover :boolean 值,如果为 true 并且当主要的服务崩溃时,Daill 将会切换到另一个服务,默认为 true

threadsafe : boolean 值,如果为 true,Dalli 会确保只有一个线程在给定的时间内使用socket,默认为 true, 设置为 false 是很危险的。

serializer : 能够使对象进行存储。

compress : boolean 值, 如果为true, Dalli 将会用 gzip 来压缩超过 1K 的数据,默认为 false

compression_min_size :当超过这个数值时就尝试压缩,默认为1K

compression_max_size :低于这个数值时才尝试压缩,默认为无限

compressor : 被压缩的对象将被存储,默认为 zlib,通过Dalli::Compressor来实现。如果正被压缩的数据使用 nginx 的 HttpMemcachedModule, 设置

memcached_gzip_flag 2 并且使用 Dalli::GzipCompressor

keepalive : boolean值,如果为 true, Dalli 将会使 socket 长久连接,默认为 true

socket_timeout : 所有的 socket 操作超时时间,默认为 0.5

socket_max_failures : 当socket 超时后,socket 操作失败,一个相同的操作将会重试,当处于一个非常缓慢的网络问题时,并不会即时去执行,默认为2

socket_failure_delay : 重试一个 socket 操作之前,这段时间进程将会睡眠,默认为 0.01,设置为 nil 将无延迟

down_retry_delay : 当一个服务因为许多失败明显的崩溃了,仅仅会在这段时间服务将会再次检查以求正常运行。不要将这个值设置得太低,不然每一个到达崩溃服务的请求也许会固定在最大的 socket_timeout,默认为 1 秒

监控

telnet 127.0.0.1 11211 连接上 memcache

输入 stats 获得状态

反省

第一次翻译,翻译得太生硬太渣了。。。

疑惑

看了官方文档,写着:“使用 memcached 来存储缓存时, Rails 会使用默认附带安装的 Dalli gem。” 但是我并没有看到有 Dalli gem 的安装,而且配置也跟 Dalli 有差别。

自带:

1
config.cache_store = :mem_cache_store, "cache-1.example.com", "cache-2.example.com"

Dalli:

1
config.cache_store = :dalli_store, "cache-1.example.com", "cache-2.example.com"

这是为毛- -!

Git – 恢复撤销篇

1、取消本地修改,恢复到上一次的commit

如果你的本地文件修改得一团乱,但是还没有提交,可以通过下面的命令恢复到上次提交时的状态( 其实就是恢复到上次的 commit,因此这次的本地修改自然没了)(注意是修改,不包括添加文件

1
git reset --hard

这条命令将会取消掉本地的所有修改,恢复到上一次我们的commit信息

2、取消 git add 的文件

有时我们会不小心git add . 所有文件,想反悔,取消某些add的文件。那么可以(还原暂存区)

1
git reset HEAD xxxx

3、取消对文件的修改

如果觉得刚才对XX文件的修改没有必要,想要恢复,那么:

1
git checkout -- xxx

4、取消已经提交的修改

如果已经做了一个提交(commit),并且马上后悔了。那么可以创建一个新的提交,在新提交里撤销老的提交所做的修改

创建一个新的,撤销(revert)了前期修改的提交(commit)是很容易的。

1
git revert HEAD

这样就创建了一个撤销了上次提交(HEAD)的新提交

1
gir revert HEAD^

撤销上上次

5、Tips

回滚到某一次 commit

git reset --hard aa89cd6bcc

这样会回滚到某个commit状态,只要知道commit id 就可以

回滚后反悔怎么办?

git reflog 记录了我们的每一次命令( commit、merge 等信息)

根据这命令来查出我们的历史 commit id,然后 git reset即可

  1. HEAD指向的版本就是当前版本,因此,Git允许我们在版本的历史之间穿梭,使用命令git reset –hard commit_id。

  2. 穿梭前,用git log可以查看提交历史,以便确定要回退到哪个版本。

  3. 要重返未来,用git reflog查看命令历史,以便确定要回到未来的哪个版本。
  4. 场景1:当你改乱了工作区某个文件的内容,想直接丢弃工作区的修改时,用命令git checkout – file。

  5. 场景2:当你不但改乱了工作区某个文件的内容,还添加到了暂存区时,想丢弃修改,分两步,第一步用命令git reset HEAD file,就回到了场景1,第二步按场景1操作。

6. 取消已经 push 的行为(重建分支)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 备份到另一分支,以防万一
git branch old_master

# 推送备份分支
git push origin old_master:old_master

# 本地仓库回退到某一版本
git reset -hard xxxx

# 删除远程分支
git push origin :master

# 推送本地还原后的分支
git push origin master

6. 取消已经 push 的行为(强制 PUSH)

1
2
3
4
5
# 本地仓库回退到某一版本
git reset -hard xxxx

# 强制 PUSH,此时远程分支已经恢复成指定的 commit 了
git push origin master --force

参考

廖雪峰 Git 教程

Git 官方

Git 官方教程

Git – Rebase 篇

git merge 和 git rebase

两者都是合并分支的功能,但是区别在于:

假如合并前是这样的:

1
2
3
D---E master
     /
A---B---C---F origin/master

使用merge后

1
2
3
  D--------E  
 /          \
A---B---C---F----G   master, origin/master

使用rebase后,就不会有G这个结点

1
A---B---C---F---D'---E'   master, origin/master

Tips

1、D’, E’ 的 commit SHA 序號跟本來 D, E 是不同的,因為算是砍掉重新 commit 了。

2、因为使用rebase算是砍掉 D E 重新commit ,那么这里就可能会造成两次冲突,需要修改两次。而merge只需要修改一次

3、因此如果是小规模改动,冲突不会太大的话,建议使用rebase,否则使用merge。

使用reabse的好处是可以让分支不会那么乱,呈线性。

进行合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
两条分支: dev  master,此时 dev 需要合并回 master

# rebase 方式
$(dev) git rebase master
$(dev) git checkout master
$(master) git merge dev

# merge 方式
$(master) git merge dev

# rebase 方式,此时dev和master的指针都是同时指向最新的commit点
# 但是 origin/dev 指针头消失了。因此
# 如果要继续使用 dev, 需要先 pull 一下,跟 远端仓库的 dev 最新 commit 点进行一次 merge ( 当然结果是一个空 commit,此时可以 push --force)
# 此时 dev 分支的 nerwork 图是:
# * b6fa04b (HEAD, master, dev, origin/master) dev01 ( 10 seconds ago )
# 原因见下面

解决冲突

1
2
3
4
5
6
7
8
9
10
11
# 解决冲突后
-------------------

# merge 方式
$ git add .
$ git commit -am 'xx'
$ git push origin master

# rebase 方式
$ git add .
$ git rebase --continue

pull 时使用 rebase 方式

1
$ git pull --rebase ( 适合同个分支 

–no-ff

因为两条分支在合并时,当第一条分支完全没做修改时,此时 Git 会用到Fast forward模式,将 HEAD 指向第二条分支的最新 commit 去,这时候我们可以用该参数来禁止fast forward模式。

git merge --no-ff这参数的作用跟rebase恰恰相反,是故意弄出一条分支线,表明某些 commit 是为了实现某个功能的。

适用于分支间的合并

Tips

一般分支间的 rebase 合并使用场景是:

1、我自己需要实现某个功能,于是开个分支 new_menu,不提交到远程分支。开发完毕用 rebase 弄回 dev 分支

2、一旦分支提交到远程分支,最好不要使用 rebase 进行分支间的合并了,会造成混乱。

3、一般来说,使用 rebase 后的 new_menu 分支就当做废弃了,如果还需要重新使用的话,继续从 dev 分支 new 出一条分支来。

4、在实验中我发现,一旦在 dev 进行 rebase master,那么 origin/dev 的指向就消失了,那是因为 dev 的原先 commit 全部消失了,进行重新 commit,因此本地的 origin/dev 也没得指向了。所以才会造成要 push 时要先pull的现象,(也就是 master分支上的最新commit点,「此时dev也是指向最新commit点」,会跟远程服务器上的origin/dev最新commit点进行merge)因此我们可以用git push --force 强制 push 来解决。

如果是pull:

如果是git push --force 的话就会成为一条直线。

整理 commit

在一个 branch 上开发一段时间后,commit 看起来会很杂乱,或者很多无谓的 commit 点,那么我们可以使用 rabase 来将多个 commit 合并成一个。

1
2
3
4
5
6
7
8
9
10
11
$ git log

=>
a
b1
b2

=>要变成

a
b
1
2
# 拿到 a 的 SHA-1 后
$ git rebase -i 49687a0a646954afdf3f4dae1f914ea793341ea2
1
2
3
4
5
6
pick 033beb4 b1
pick d426a8a b2
# Rebase 49687a0..d426a8a onto 49687a0
# pick = use commit 
# squash = use commit, but meld into previous commit
# pick 会执行该 commit, squash 则将该 commit 合并到前一个 commit

于是我们修改成:

1
2
3
4
pick 033beb4 b1
squash d426a8a b2

# 保存退出后会要求输入新的 commit 信息,后可通过 git log 查看。

Tips

1、这功能的使用场景是 commit 还没有 push 到远程分支,一旦 push 到远程分支就不要用了。( 虽然可以用 –force 强制 push,视情况而定 )

Git – Push 和 Pull 篇

git push

将本地仓库的更新推送到远程仓库 remote

git push [remote-name] [branch-name]

git push origin master

1
2
3
4
5
6
# 如果当前分支跟远程分支有 tracking 关系,那么将会自动推送当前分支到对应的远程分支
# 如果没有 tracking 关系,则报错
git push origin

# 如果主机名只有一个 origin,那么连 origin 都可以省略
git push

查看 tracking 关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat ./.git/config

# 此时没有 tracking 关系
==>
[core]
  repositoryformatversion = 0
  filemode = true
  bare = false
  logallrefupdates = true
  ignorecase = true
  precomposeunicode = true
[remote "origin"]
  url = git@github.com:linjunzhu/test_branch.git
  fetch = +refs/heads/*:refs/remotes/origin/*

添加 tracking 关系

1
2
3
4
5
git push -u origin dev  (这句同时指定了 origin 为默认主机)
或者
git branch -u dev origin/dev
或者
git push --set-upstream origin master

查看 tracking 关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ cat ./.git/config

# 此时有 tracking 关系
==>
[core]
  repositoryformatversion = 0
  filemode = true
  bare = false
  logallrefupdates = true
  ignorecase = true
  precomposeunicode = true
[remote "origin"]
  url = git@github.com:linjunzhu/test_branch.git
  fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
  remote = origin
  merge = refs/heads/master

git pull

拉取更新

将远程仓库的更新拉取到本地的「远程仓库」并且进行「merge」操作

1
2
3
4
5
$ git pull origin master

# 相当于两步操作
$ git fetch origin master
$ git merge origin/master

Tips

1、如果当前分支跟远程分支是没有 tracking 关系的,那么执行git pull后,会报错

2、如果当前分支有 tracking 关系,会 download 远程仓库的所有分支代码到本地的 origin/分支上,但是只会合并当前分支,其他分支相当于 git fetch 命令一样。

git pull 和 git fetch 的使用区别

  1. git pull origin master: 意思是从远程端下载最新版本到当前分支,并且自动合并

  2. git fetch origin master: 意思是从远程端下载最新版本到当前分支,但是并不合并。因此如果是git fetch 的话,就需要做两步操作。

1
2
3
4
5
6
7
8
9
git fetch origin master

git log -p master origin/master   (查看修改内容)

git  merge orgin/master    (合并分支)

或者进行 rebase

下章讲解

Tips

一、不带任何参数的 git push有两种模式

  1. matching模式,会推送所有对应远程分支本地分支
  2. simple模式,只推送当前分支 (如果有 tracking 关系的话)

默认没有设置,需要打下命令

1
git config --global push.default simple

二、关于pushandpull

一般如果没把握,最好写全。

1
2
git push origin dev
git pull origin dev

三、切换远程分支

当小伙伴的新建分支 push 到远程分支,我们也想要拉取这条分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 无论有无 tracking, git fetch 都会拉取所有分支
git fetch origin test_branch

# 拉取所有分支
git fetch

# 或者直接在有 tracking 关系的分支上(拉取所有分支,但只合并当前分支)
git pull

git checkout test_branch

# 也可以直接本地创建分支,然后 pull 下
git checkout -b test_branch -t
git pull origin test_branch

Git – 基础篇

Git 和 SVN 的区别

Git 和 SVN 最大的区别概念在于:Git 是分布式,而 SVN 是集中式管理。

一、SVN

所谓集中式版本控制工具,总有一个中心服务器,提供一个项目仓库,大家的代码的提交都是统一提交到这个中心服务器上的

SVN 的模式:

  1. 写代码。
  2. 从服务器拉回服务器的当前版本库,并解决服务器版本库与本地代码的冲突。
  3. 将本地代码提交到服务器。

每个人的 commit 都是直接提交到服务器。容易造成与其他人的冲突

二、Git

分布式版本控制工具类似Git,可以有多个代码仓库,比如可以在本地。同时它可以方便的进行代码仓库合并

Git 的模式:

  1. 写代码。
  2. 提交到本地版本库。
  3. 从服务器拉回服务器的当前版本库,并解决服务器版本库与本地代码的冲突。
  4. 将远程库与本地代码合并结果提交到本地版本库。
  5. 将本地版本库推到服务器。

三、总结

虽然 Git 和 SVN 看起来很像,都有一个服务器库,但其实 Git 是没有中心服务器这个概念的,尽管每个人平时都是先将代码统一提交到中央服务器再统一 pull 其他人的代码,但实际情况是我们可以先 pull 张三的库,再 push 给李四等等操作,只要知道对方的IP地址仓库地址即可( 不过很少这么做)

在 Github 上,你可以任意 fork 别人的仓库,按照自己的需求进行改造,或者提出 pull request 请原作者 merge 你所修改的代码。这里面没有‘中心’这个概念。

每一次的 commit,SVN 都需要网络,而 Git 则不用,因为有本地仓库的概念,这样的好处是,即使你短时间断网都无所谓,还是可以不断的 commit,来追踪版本。

分布式版本控制系统通常也有一台充当“中央服务器”的电脑,但这个服务器的作用仅仅是用来方便“交换”大家的修改,没有它大家也一样干活,只是交换修改不方便而已。

Git 基础知识

Git 的对象模型

https://ruby-china.org/topics/20723 这帖子讲得很好。 总共分为:

  1. commit
  2. tree
  3. blog

commit : 提交信息

tree :指向其他的 tree / blog

blog:文件的内容

Git 的结构

分为工作区,暂存区,以及本地仓库

基本操作

1
2
3
4
5
6
7
8
# 将文件加入暂存区(注意并不包括被删除文件)
$ git add .

# 将文件提交到本地仓库(HEAD)
$ git commit -am 'First commit'

$ 将改动推送到远程仓库
$ git push origin master

关于 -am 的解释

git commit -am 'message': git add 和 git commit同时执行,并且会自动移除掉不在工作区的文件

1
2
3
# Git 文档
-a, --all
Tell the command to automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected.

删除文件有两种操作

1、手动 rm,但此时文件还存在于 Git 控制下,需要git commit -am'msg'

2、git rm file_name, 然后git commit -m “xx” 即可。

提交文件我一般喜欢这么干:

1
2
3
git add .     #提交新增、更改后的文件
git commit -am 'Hello' # commit 并且保存更改后的文件以及删除的文件
git push origin master

也有人喜欢

1
2
3
git add -A (保存所有改动,包括新增删除文件)
git commit -m 'Hello'
git push origin master

更新


Git 2.0 后对add做了些修改。

1
2
3
4
5
6
Files to add content from. Fileglobs (e.g.  *.c) can be given to add all matching files. Also a leading directory
name (e.g.  dir to add dir/file1 and dir/file2) can be given to update the index to match the current state of the
directory as a whole (e.g. specifying dir will record not just a file dir/file1 modified in the working tree, a
file dir/file2 added to the working tree, but also a file dir/file3 removed from the working tree. Note that older
versions of Git used to ignore removed files; use --no-all option if you want to add modified or new files but
ignore removed ones.

git add . 命令现在是有包含被删除文件的,因此现在只需要这么做:

1
2
3
4
5
6
# 将所有文件加入暂存区
git add .
# 提交
git commit -m 'Hello'
# push
git push origin master

Rails 中的 Assets Pipeline 机制

前言

我以前一直认为,Rails 的预编译就是把所有 css 和 js 分别整合成一个文件,并且进行压缩等操作。

其实并不是,Rails 预编译的意思,就只是预先编译那些 scss, coffee 等文件为 css, js 的意思。

而给文件名加上 hash 值得,整合成一个文件,压缩等等所有操作,都是Rails 中一个叫做 Assets Pipeline 功能实现的

Asset Pipeline

Asset Pipeline 提供了一个框架,用于连接、压缩 JavaScript 和 CSS 文件。还允许使用其他语言和预处理器编写 JavaScript 和 CSS,例如 CoffeeScript、Sass 和 ERB。

Asset Pipeline在Rails4中已经从框架中提取出来成为:sprockets-rails gem

Asset Pipeline有三个主要功能:

  1. 连接所有静态资源,分别成为一个js css文件
  2. 压缩静态资源
  3. 允许使用高级语言编写静态资源,如 sass coffee

Rails 4 会自动把 sass-rails、coffee-rails 和 uglifier 三个 gem 加入 Gemfile。Sprockets 使用这三个 gem 压缩静态资源:

1
2
3
gem 'sass-rails'
gem 'uglifier'
gem 'coffee-rails'

默认情况下,在生产环境中,Rails 会把预先编译好的文件保存到 public/assets 文件夹中,网页服务器会把这些文件视为静态资源。在生产环境中,不会直接伺服 app/assets 文件夹中的文件。

默认编译的文件包括 application.js、application.css 以及 gem 中 app/assets 文件夹中的所有非 JS/CSS 文件(会自动加载所有图片)

1
2
[ Proc.new { |path, fn| fn =~ /app\/assets/ && !%w(.js .css).include?(File.extname(path)) },
/application.(css|js)$/ ]

这个正则表达式表示最终要编译的文件

最终会编译成: application-adfea1231s.js , application-sfef234df.css, xxx-sdfsdfe.png 等

因此如果我们想要自己添加单独编译出来的文件的话:

1
config.assets.precompile += ['admin.js', 'admin.css', 'swfObject.js']

注意,即便想添加 Sass 或 CoffeeScript 文件,也要把希望编译的文件名设为 .js 或 .css。( 因为 sass 最终也是要解析成 .css 的)

对 Bootstrap 遇到的坑

之前引入了 Bootstrap 后,预编译一直出现

1
Sass::SyntaxError: Undefined variable: "$alert-padding"

之后折腾了许久,才发现我在 assets.rb 里设置了

1
2
config.assets.precompile += [*.js]
config.assets.precompile += [*.css]

因为我把所有 js css 都单独编译了,而 bootstrap 有两份 css 文件,其中一份定义了 bootstrap 的变量,如: $alert-padding,而这个变量在第二个 css 文件中被引用,因此,当第二个文件单独编译时,就会发现找不到这个变量了。

解决方法:

  1. 按需加入自己要的文件

  2. 编译所有静态资源,但是去除bootstrap相关的( 这里还没去除 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# config/application.rb
config.assets.precompile << Proc.new do |path|
  if path =~ /\.(css|js)\z/
    full_path = Rails.application.assets.resolve(path).to_path
    app_assets_path = Rails.root.join('app', 'assets').to_path
    if full_path.starts_with? app_assets_path
      puts "including asset: " + full_path
      true
    else
      puts "excluding asset: " + full_path
      false
    end
  else
    false
  end
end

静态资源优化

1. 设置过期时间

编译好的静态资源存放在服务器的文件系统中,直接由网页服务器伺服。默认情况下,没有为这些文件设置一个很长的过期时间。为了能充分发挥指纹的作用,需要修改服务器的设置,添加相关的报头。

针对 Nginx

1
2
3
4
5
6
7
location ~ ^/assets/ {
  expires 1y;
  add_header Cache-Control public;

  add_header ETag "";
  break;
}

2. 使用 gzip 压缩

Sprockets 预编译文件时还会创建静态资源的 gzip 版本(.gz)。网页服务器一般使用中等压缩比例,不过因为预编译只发生一次,所以 Sprockets 会使用最大的压缩比例,尽量减少传输的数据大小。网页服务器可以设置成直接从硬盘伺服压缩版文件,无需直接压缩文件本身。

在 Nginx 中启动 gzip_static 模块后就能自动实现这一功能:

1
2
3
4
5
6
location ~ ^/(assets)/  {
  root /path/to/public;
  gzip_static on; # to serve pre-gzipped version
  expires max;
  add_header Cache-Control public;
}

如果编译 Nginx 时加入了 gzip_static 模块,就能使用这个指令。Nginx 针对 Ubuntu/Debian 的安装包,以及 nginx-light 都会编译这个模块。否则就要手动编译:

1
./configure --with-http_gzip_static_module ( 还没试过)

本地预编译

为什么要在本地预编译静态文件呢?原因如下:

  1. 可能无权限访问生产环境服务器的文件系统;
  2. 可能要部署到多个服务器,避免重复编译;
  3. 可能会经常部署,但静态资源很少改动;

不过有两点要注意:

  1. 一定不能运行 Capistrano 部署任务来预编译静态资源;
  2. 必须修改下面这个设置;

在 config/environments/development.rb 中加入下面这行代码:

1
config.assets.prefix = "/dev-assets"

修改 prefix 后,在开发环境中 Sprockets 会使用其他的 URL 伺服静态资源,把请求都交给 Sprockets 处理。但在生产环境中 prefix 仍是 /assets。如果没作上述修改,在生产环境中还是会从 /assets 伺服静态资源,但是除非再次编译,否则看不到文件的变化。

但为什么部署的时候不会再次编译呢?明明文件内容都发生变化了,奇怪?

之前预编译后,public下有/assets文件夹(production环境),但是请求的静态资源却全部都是 development 下的请求方式,(不带hash值的文件名),奇怪- - 以后有空好好研究下。

Ubuntu下修改Mysql5.5中文编码

前言

由于之前在 Ubuntu 下安装 Mysql时,忘记选择编码,于是全部都是 Latin 编码,折腾了一阵子搞定了

Begin

在网上搜了一阵教程,明白 Mysql 安装后的一系列目录:

1
2
3
4
5
6
/etc/init.d/mysql  ( 启动脚本)
/etc/mysql/my.cnf ( MySQL 的配置文件 )
/var/lib/mysql   ( 数据库文件存放路径)
/usr/lib/mysql  Mysql 安装路径

命令:service mysql start / stop / restart

Mysql 版本: 5.5以上

Doing

按照网上教程,打开 my.cnf,在[client] [mysqld] 下加上 default-character-set=utf8

结果启动mysql报错,于是我查看了/var/log/mysql/error.log的日志,这里用cat命令load不出东西,要直接vim进去才行,奇怪。

报错原因:unknown variable 'default-set-server=utf8'

最终解决: Mysql 版本5.5 以上,需要这么做:

1
2
3
4
5
6
7
8
9
10
11
[client]下添加:

default-character-set=utf8

[mysqld] 下添加:

character-set-server=utf8

[mysql] 下添加

default-character-set=utf8

然后重启mysql

sudo service mysql restart

进入mysql终端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
show variables like '%character%';
#会有如下显示:
+--------------------------+----------------------------+
| Variable_name            | Value                      |
+--------------------------+----------------------------+
| character_set_client     | utf8                       |
| character_set_connection | utf8                       |
| character_set_database   | utf8                       |
| character_set_filesystem | binary                     |
| character_set_results    | utf8                       |
| character_set_server     | utf8                       |
| character_set_system     | utf8                       |
| character_sets_dir       |/usr/share/mysql/charsets/ |
+--------------------------+----------------------------+

Web 缓存 以及 Rails 缓存实践 – 后端篇

1、 Rails 中的 ETag

在 Rails 中默认使用Rack::ETag middleware中间件,它会自动给无 ETag 的 response 加上 ETag,再根据 ETag 来判断资源是否更改,从而返回 200 / 304。

注意: 返回304并不是说服务器没有处理资源直接就返回304,而是完全的处理完了资源,生成了response准备响应了,才进行生成 ETag,然后进行判断,这样虽然也会消耗服务器的资源,但是可以大大减少客户端传输网络的时间。

之前有个疑惑:

1
2
3
为什么要等到生成完整 response 再加上 ETag 进行判断呢?我的想法是:因为判断
资源是否有变化,所以需要判断`response内的某些资源`是否有变化,因此迟早都要生成response,
所以是否等到生成完整response再加上ETag是无所谓的

2、Nginx

Nginx 1.7.3 以前如果开启了 gzip on,Nginx 会将 Rails响应中的 ETag 头去掉。

Nginx 1.7.3 后开始支持 Weak_ETag,会将 Rails响应中的 ETag 转换为 Weak_ETag,不过 Rack::ETag在1.6.0 也默认生成 Weak ETag

Nginx 默认就会给静态文件打上 ETag 和 Last-Modified

3、Rails的缓存机制

服务器端缓存区存储有两种方式:

  1. 硬盘
  2. 内存

运行流程图:

画得有点呵呵,将就着看吧。

说明几点:

  1. 每次请求都会重新计算cache_key,而计算cache_key则会进入到数据库去查询,比如products需要查询products.max(&:updated_at)
  2. 缓存本质倒不是查不查,而是把一个长查询转化成短查询。
  3. 当资源更新时,对应缓存区的缓存会过期,但过期不是删除,缓存还是会存在缓存区中,只是不再命中,所以基于 file 的缓存方案会产生许多重复的垃圾,dhh 推荐使用 memcache 就是把删除缓存的操作交给 memcache 来做。

4、Rails中使用缓存

在 Rails 中使用缓存是个大学问

4.1、fresh_when

前文说到,Rails 默认的中间件Rack::ETag middleware会自动给无ETagresponse加上ETag,那么,有没办法在生成完整的response之前(节省资源)我们自己加上ETag呢? Rails 提供了 fresh_when helper

1
2
3
4
5
6
class ArticlesController
  def show
    @article = Article.find(params[:id])
    fresh_when :last_modified => @article.updated_at.utc, :etag => @article
  end
end

下次用户再访问的时候,会对比request header里面的If-Modified-Since和If-None-Match,如果相符合,就直接返回304,而不再生成response body。

但是这样会遇到一个问题,假设我们的网站导航有用户信息,一个用户在未登陆专题访问了一下,然后登陆以后再访问,会发现页面上显示的还是未登陆状态。或者在app访问一篇文章,做了一下收藏,下次再进入这篇文章,还是显示未收藏状态。解决这个问题的方法很简单,将用户相关的变量也加入到etag的计算里面:

1
2
 fresh_when :etag => [@article.cache_key, current_user.id]
 fresh_when :etag => [@article.cache_key, current_user_favorited]

和fresh_when相比,自动etag能够节省的只是客户端时间

4.2 Fragment Caching

Rails 中有几种 cache,这种是最常见使用最多的。

如下,有个项目页面:

接下来我们看看,如果这个页面的某一条数据,比如「任务 A-1」 的内容被改变了,会发生什么。

首先,这条任务自己对应的 L4 Todo Item 缓存失效了,所以在拼装外面的 L3 级「任务清单A」缓存的时候,会从缓存里获取任务 A-2、A-3 的缓存,速度嗖嗖快,快到可以忽略不计,然后对任务 A-1 重新渲染一次,放入缓存,这样「任务清单A」通过直接从缓存里读取两条任务(A-2 和 A-3),以及渲染一条新的( A-1 )生成了整个 L3 Todolist Item 的页面片段。剩下的「任务清单B」和「任务清单C」,都没有变化,因此由在生成「任务清单」Section 缓存的时候,直接拼装即可。

其它几个 Section 片段因为和任务没有任何关系,所有缓存都不会过期,因此这几个 Section 的页面片段都是直接从缓存里捞出来,同样嗖嗖快。

最后,整个项目详情页把这几个 Section 拼装起来,返回给客户。从上面的过程可以看出,只有「任务 A-1」 这个片段的页面被重新渲染了。

所以,这种套娃式的缓存,能够保证页面缓存利用率的最大化,任何数据的更新,只会导致某一个片段的缓存失效,这样在组装完整页面的时候,由于大量的页面片段都是直接从缓存里读取,所以页面生成的时间开销就很小。

那么,套娃是如何在缓存中存取页面片段的呢?主要是靠一个叫做 cache_key 的东西来决定的。

4.2.1、cache_key

代码初级版:

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
27
28
29
<% cache @project do  %>
  <%= @project.name %>

  <!-- Section Topics -->
  <% cache @top3_topics.max(&:updated_at) do %>
    <%= render partial: 'topics/topic', collection: @top3_topics %>
  <% end %>

  <!-- Section todolists -->
  <% cache @todolists.max(&:updated_at) do %>
    <%= render partial: 'todolists/todolist', collection: @todolists %>
  <% end %>

  <!-- Section uploads -->
  <% cache @uploads.max(&:updated_at) do %>
    <%= render partial: 'uploads/upload', collection: @uploads %>
  <% end %>

  <!-- Section documents -->
  <% cache @documents.max(&:updated_at) do %>
    <%= render partial: 'documents/document', collection: @documents %>
  <% end %>

  <!-- Section calendar_events -->
  <% cache @calendar_events.max(&:updated_at) do %>
    <%= render partial: 'calendar_events/calendar_event', collection: @calendar_events %>
  <% end %>

<% end %>

单个资源

可以看到,最外层有个<% cache @project %>,这个cache使用@project作为参数,cache内部会对该对象进行处理,计算出cache_key

大概是:views/projects/1-20140906112338

Rails 会使用该cache_key 作为key,将包含的所有东西(包括数据和html)通通放入缓存区。每次有请求到来时,就会重新计算 cache_key,然后到缓存区进行查找,命中则返回,不命中则服务器查询资源,重新将资源放入缓存区。

当资源更新时,cache_key 也随之变化(注意,cache_key 大部分需要自己设计),因此就不会命中缓存,缓存自然过期。

注意: 如果不生成cache_key,缓存永远都不会变!!!无论资源是否更新 注意: 缓存过期不代表缓存被删除,因为缓存区会存在大量垃圾,需要memcached来解决。

多个资源

那如果是资源列表呢?如果我们直接把 @top3_topics 对象作为 cache 的参数 <% cache @top3_topics %>,得到的 cache_key 实际上会是这样的形式:

views/topics/3-20140906112338/topics/2-20140906102338/topics/3-20140906092338

显然,这样做不太好,因此我们可以取这组数据内最新一个被更新的数据的updated_at时间戳,这样会生成:

views/20140906112338

但这样会出现一个问题,假如此时@top_topics为空,那么我们此时是对 nil 进行缓存,不幸的是,所有 nil 的 cache_key 都一模一样,这样就会造成混乱。再加上,加入任务清单 Section 和讨论 Section 最后更新的那条数据的 updated_at 时间戳恰好一样,也会造成两个缓存片混淆的问题。

解决方法就是给每个cache都加上model名 <% cache [:topics, @top3_topics.max(&:updated_at)] %>

代码更新版:

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
27
28
29
<% cache @project do  %>
  <%= @project.name %>

  <!-- Section Topics -->
  <% cache [:topics, @top3_topics.max(&:updated_at)] do %>
    <%= render partial: 'topics/topic', collection: @top3_topics %>
  <% end %>

  <!-- Section todolists -->
  <% cache [: todolists,  @todolists.max(&:updated_at)] do %>
    <%= render partial: 'todolists/todolist', collection: @todolists %>
  <% end %>

  <!-- Section uploads -->
  <% cache [: uploads,  @uploads.max(&:updated_at)] do %>
    <%= render partial: 'uploads/upload', collection: @uploads %>
  <% end %>

  <!-- Section documents -->
  <% cache [: documents,  @documents.max(&:updated_at)] do %>
    <%= render partial: 'documents/document', collection: @documents %>
  <% end %>

  <!-- Section calendar_events -->
  <% cache [: calendar_events,  @calendar_events.max(&:updated_at)] do %>
    <%= render partial: 'calendar_events/calendar_event', collection: @calendar_events %>
  <% end %>

<% end %>

4.2.2 touch!机制

我们回过头来再看看套娃缓存的读取机制,访问项目详情页的时候,首先读取最外层的大套娃 <% cache @project %> ,如果这个缓存片对应的 cache_key在缓存里能找到,则直接取出来并且返回,如果缓存过期,则读取第二级套娃 — 几个列表 Section 缓存,这些缓存根据列表里最新一条数据的更新时间生成 cache_key,如果最新一条数据的更新时间没有变化,则缓存不过期,直接取出来供页面拼装用,如果缓存过期,则继续读取各自的第三级套娃。

等等,这里有个问题,如果我改变了一条任务的内容,也就是作废了任务 partial 自己的缓存,但是包裹任务的任务清单,以及包裹任务清单的项目都没有变化,这样当页面加载的时候,读取到的第一个大套娃 – <% cache @project %> 都没有更新,会直接返回被缓存了的整个项目详情页,所以根本不会走到渲染更新的任务 partial 那里去。对于这个问题的解决方案,是 Rails 模型层的 touch 机制。

简单的说,我们需要让里面的子套娃在数据更新了以后,touch 一下处在外面的套娃,告诉它,嘿,我更新了,你也得更新才行。我们直接看看这个代码片段:

1
2
3
4
5
6
7
8
9
10
11
class Project < ActiveRecode::Base
  #...
end

class Todolist < ActiveRecode::Base
  belongs_to :project, touch: true
end

class Todo < ActiveRecode::Base
  belongs_to :todolist, touch: true
end

由于 touch 的存在,当 todo 的某条任务修改时,会自动通知到 todolist, 让其修改 updated_at 属性,而 todolist 也会修改到 project,一层层保证整个缓存系统的正常运作。

5、Rails 里使用缓存的坑

1
2
3
4
5
<% cache @project %>
 <% cache [:todolists, @todolists.max(&:updated_at)] do %>
       <span>清单所属项目: <%= @project.name %></span>
     <%= render partial: 'todolists/todolsit', collection: @todolists %>
<% end %>

当出现以上情况时,属于父级cache的属性出现在子级cache内,当@project的内容更改了,这时 @todolists 的 cache_key 并没有改变,也就是说这段缓存没有过期,<%= @project.name %> 显示的还是过去的 name

解决方法:

1
2
3
4
5
<% cache @project %>
 <% cache [:todolists, @project, @todolists.max(&:updated_at)] do %>
       <span>清单所属项目: <%= @project.name %></span>
     <%= render partial: 'todolists/todolsit', collection: @todolists %>
<% end %>

1
2
3
4
5
<% cache @project %>
 <% cache [:todolists, @project.name, @todolists.max(&:updated_at)] do %>
       <span>清单所属项目: <%= @project.name %></span>
     <%= render partial: 'todolists/todolsit', collection: @todolists %>
<% end %>

这样子当修改@project时,也会修改子级的 cache_key

6、注意

当请求过来时,还是会经过controller,进行lazy select,再经过erb,再进行真正的 cache_key 计算,当然,此时 controller 中查询的那些还没有真正的查询,就仅仅只是计算了 cache_key, 比如:

1
2
3
<%= cache @articles.maximum(:updated_at) do %>
    .....
<% end %>

因为是 lazy select,所以这时候连 @articles 都没真正查询出来,直接变成了查询最大 updated_at 的语句。

(有些在 controller 中对那些查询对象进行了操作,就不是 lazy select了)

分割好粒度,否则会得不偿失

还有第二个坑,写得脑仁疼,不写啦,看下面参考文

参考

总结 web 应用中常用的各种 cache: https://ruby-china.org/topics/19389

Cache 在 Ruby China 里面的应用: https://ruby-china.org/topics/19436

说说 Rails 的套娃缓存机制: https://ruby-china.org/topics/21488?page=1#replies

官方缓存讲解:http://guides.ruby-china.org/caching_with_rails.html

Web 缓存 以及 Rails 缓存实践 – 前端篇

1、前言

之前一直对缓存处于模模糊糊的概念,网上的许多文章也说得比较模糊,经过大量的实践以及搜索后,总结了下 Web 缓存以及在 Rails 中常用的缓存

2、概览

我们平常所使用的浏览器,是有带缓存区的。

3、 Expires / Cache-Control

两者功能差不多,但后者可供选择的参数更多,两者若同时存在,会被 cache-control 所覆盖

Expires

用来表明该内容什么时候过期, HTTP/1.0 推出

1
2
# 表示在这个时间点之前资源都不会过期,可以直接从本地浏览器缓存区里拿
Expires:Tue, 03 May 2016 09:33:34 GMT

Cache-Control

Cache-control 是 HTTP/1.1 推出的,有更多的参数可以选择

1
2
3
4
5
6
7
8
9
10
11
public: 共有缓存,可被缓存代理服务器缓存,比如CDN

private: 私有缓存,不能被共有缓存代理服务器缓存,可被用户的代理缓存如浏览器。

max-age:表示在这个时间范围内缓存是新鲜的无需更新。类似Expires时间,不过这个时间是相对的,而不是绝对的。也就是某次请求成功后多少秒内缓存是新鲜的。

no-cache:这里不是不缓存的意思,只是每次在使用缓存之前都强制发送请求给源服务器进行验证,检查文件有没改变

no-store:就是禁止缓存,不让浏览器保留缓存副本

must-revalidate:告诉浏览器,你这必须再次验证检查资源是否过期, 返回的代号就不是200而是304了。

需要注意的是,该参数 request headerresponse header 都可使用。但是总的来说,前者并不重要,重要且频繁使用的是后者 response header

Request 内的 Cache-Control

除了 AJAX 可以来自定义这个 Cache-Control, 一般来说都是交给浏览器去控制。

1
2
3
4
# 这里是直接告诉 代理缓存服务器 / 服务器,我要最新的副本 ( 然后再通过 ETag / If-None-Match 去判断是否从缓存区里拿 )
# 之前很疑惑,既然 response 都有 no-cache 了,那 request 设这个值的意义何在呢?
# 目的是告诉「代理缓存服务器」强制向「服务器」对资源进行重新验证,避免权限过期后,代理缓存服务器还是返回之前的资源副本。
cache-control: no-cache

Response 内的 Cache-Control

1
2
# 允许客户端进行缓存,并且,需要立即进行请求服务器验证资源是否过期
cache-control: no-cache
1
2
3
# 表示 1000 秒内,浏览器需要这资源时,直接从缓存区内拿,而不用重新发请求
# 当过期时,发送「条件 GET 请求」
cache-control: max-age: 1000
1
2
3
4
5
# 经常会看到这样写
cache-control: max-age: 0, must-revalidate

# 其实等同于
cache-control: no-cache

总而言之, Cache-Control 这个值主要是 response 来使用,request 可以不用去管它。

4、条件 GET 请求

如果浏览器缓存中已经有一个 HTML 内容元素的副本,却不能保证该缓存是否有效,这时,一个条件 GET 请求就出现了。

有两种方式判断资源是否需要过期:

浏览器会附带If-None-Match属性(内容id ) 或 If-Modified-Since(修改内容时间戳)

ETag / If-None-Match

ETag 指响应内容的 MD5 值, 对应请求的 If-None-Match属性, 如果两者一致,则资源不过期

Last-Modified / If-Modified-Since

Last-Modified 指响应内容的最后修改时间 ,对应请求的 If-Modified-Since,如果最后修改时间一致,则资源不过期

小结

ETag / Last-Modified 很多时候都会共同使用,但是浏览器会先以 ETag 为准,因为根据时间来判断是否过期,会有误差。

5、 Web Server

静态文件的请求我们一般会用 Web Server 来 hold 住,比如 Nginx

可以这样设置:

1
2
3
4
5
6
7
8
location ~* /assets/ {
    # 开启压缩
    gzip_static on;
    # expires 过期时间为最长
    expires max;
    # 设置为 public,即 CDN 服务器也可以缓存
    add_header Cache_Control public;
}

PS:为了兼容,Nginx 如果设置了expires max 会自动往 cache-store 中塞max-age

6、TIPS

1、如果已经打开网页,并且在地址栏按一次 enter, 那么所有缓存措施会生效,比如设置了 max-age 并且未过期的情况,会直接从缓存区拿数据

2、如果按浏览器的「刷新」按钮,会强制重新发送请求到服务器,但这时候 Etag / Last-Modifed 会派上用场

3、如果按浏览器的「强制刷新」,会强制重新发送请求到服务器,并且所有缓存失效。

4、服务器返回响应时,是先返回状态码,再返回响应体。

5、其实服务器还是执行了代码,生成了该有的响应,只不过浏览器知道资源未过期,因此把 响应体抛弃掉了。但总的来说还是节省了浏览器的时间。

6、条件 GET 请求中的 If-Modified-Since / ETag 是需要服务器支持的。

7、总结