为了提升网站的访问速度,需要尽可能的减少客户端与服务器端的请求数量与传输的数据量以及服务器从硬盘或数据库读取内容的频率,最常用的解决方法就是引入缓存。当然,虽然缓存在提升性能表现的很好但也不可避免的带来一些问题,比如缓存不同步,缓存不及时更新等问题,这篇文章主要总结缓存对静态文件资源进行缓存所带来的问题以及如何解决。

当然,解决缓存的办法很简单,就是不需要缓存或者每次发布之后的文件名都改变,导致上一个版本的缓存失效。显然这并不是我们的目的,我们的目的是前后两次迭代发布上线要做到文件名称随文件内容同步改变,这样才能在快速迭代发布上线中实现缓存的最大利用率。

涉及到的缓存实现分客户端与服务器端两种。

  1. 客户端缓存
    客户端缓存,就是把不经常变动的文件数据缓存在客户端,便于下次访问时直接从本地获取缓存的文件。要实现本地文件缓存,需要满足两个条件:

    • 服务器端在请求返回头部中设置cache-control或expires等...
    • 需要缓存的文件前后两次必须同名
  2. 服务器端缓存
    服务端缓存就是后端服务把文件从硬盘中读取返回给客户端之后,把文件内容缓存起来,当下次客户端再请求该文件时,直接从缓存(内存?)读取而不需要从硬盘读取,提升访问速度与减少服务器压力。然后返回给客户端并设置缓存头部,实现客户端缓存。

设置缓存头部很简单,而文件名称与文件内容同步改变就涉及到前端打包,而NEJ打包已经实现此功能。

最近刚好遇到这样的问题,由于打包方式的问题加上服务器vanish缓存的存在,最终在vanish缓存中导致新版本的文件名缓存了旧版本文件的内容,导致报错。为了避免此类问题的再次出现或者出现后方便解决,于是对此次问题做了个总结。问题的出现涉及到多个步骤 ,结合教育产品部门的通用打包方式与ndp部署方式以及服务器缓存等方面阐述问题的发生、解决、思考等过程。此次问题的解决感谢各位后端大大的耐心解答。

1、前端NEJ打包规则

  1. release.conf配置对打包结果的影响
    NEJ打包工具配置对静态文件的处理常用方式大致有3种:

    • 文件名固定
      如prefix_relative_file_path.js, filename.html (本地、测试环境使用)

    • 固定文件名+?内容md5值版本号
      每次打包根据文件内容生成版本信息,版本号通过地址的查询串携带,如/prefix_relative_file_path.js?65b61c1aabc7a07c9c7525a398b4555a,filename.html?65b61c1aabc7a07c9c7525a398b4555a

    • 固定文件名+md5值.js
      每次打包根据文件内容生成md5值,把md5值添加在文件名称中,如/prefix_relative_file_path_65b61c1aabc7a07c9c7525a398b4555a.js,filename_65b61c1aabc7a07c9c7525a398b4555a.html

教育产品部门前期主要使用的是第二种方式,期间遇到问题的几率很小,现在改成了js使用第三种,而模块html继续使用第二种的方式,属于修改不完全的方式,当然也会遇到缓存问题,只是影响范围从页面下降到了某一模块。所以要完全解决这个问题,必须js、html都是用第三种方式。

NEJ以NEJ打包工具也在迭代当中,以上的三种打包方式有一定的版本要求
建议: nej >= 0.4.0 、 nej打包工具 >= 1.6.6。此外还需要在release.conf配置文件中添加一些参数

###############################################
###############     版本配置    ###############
###############################################
# 静态资源是否自动带版本号
# 静态资源即DIR_STATIC配置的目录下的所有资源文件的引入均自动带上版本信息
# 忽略自带版本的静态资源路径,如url(/path/to/a.png?v=1234)
VERSION_STATIC = true
# 版本号生成规则,默认自动模式
# 0 - 自动模式,根据文件内容生成,版本号通过地址的查询串携带,如/a.js?9e107d9d372bb6826bd81d3542a419d6
# 1 - 随机模式,每次生成随机版本信息,不重复,版本号通过地址的查询串携带,如/a.js?123456
# * - 固定模式,配置字符串作为文件名后缀,地址的查询串中不再携带版本信息,
#     如配置为v1,则生成的文件文件名后追加此配置值,生成文件名如a_v1.js
#     配置中可以使用以下变量来表示内建值,如果出现以下变量,则不再追加原文件名
#     [RAND]     - 替代随机版本号,如[FILENAME]_[RAND]则生成文件a_9865734934.js
#     [VERSION]  - 替代文件的MD5值,如v2_[VERSION]则生成文件为v2_9e107d9d372bb6826bd81d3542a419d6.js
#     [FILENAME] - 替代文件名,系统自动生成的唯一文件名标识,如[FILENAME]_v2则生成文件a_v2.js
VERSION_MODE = [FILENAME]_[VERSION]

#静态资源版本号生成规则,默认自动模式,配置说明如下:

# 0 - 自动模式,根据文件内容生成,版本号通过地址的查询串携带,如/a.png?9e107d9d372bb6826bd81d3542a419d6
# 1 - 随机模式,每次生成随机版本信息,不重复,版本号通过地址的查询串携带,如/a.png?123456
# * - 固定模式,配置字符串作为文件名后缀,地址的查询串中不再携带版本信息,如配置为v1,则生成的文件文件名后追加此配置值,生成文件名如a_v1.png
# 固定模式配置中可以使用以下变量来表示内建值,如果出现以下变量,则不再追加原文件名

# [RAND] - 替代随机版本号,如[FILENAME]_[RAND]则生成文件a_9865734934.png
# [VERSION] - 替代文件的MD5值,如v2_[VERSION]则生成文件为v2_9e107d9d372bb6826bd81d3542a419d6.png
# [FILENAME] - 替代文件名,系统自动生成的唯一文件名标识,如[FILENAME]_v2则生成文件a_v2.png
VERSION_STATIC_MODE = [FILENAME]_[VERSION]
#模块文件的版本模式,支持配置的值如下所示

# 0 - 使用查询参数的版本【默认配置】,如 index.html?12343423432
# 1 - 使用路径版本,比如 index.html 的模块生成 index_13432233243.html 的打包文件
NEJ_MODULE_VERSION = 1

期间由于打包工具更新不同步问题(nej打包工具与工程依赖的NEJ库),导致打包结果中出现两种不同的打包结果,即模块名称引用优先顺序命名方式与文件路径md5值命名方式同时存在,就会出现以下问题,导致文件错误缓存问题很容易发生

// 文件路径md5值命名方式
EDU("f7e564f950f1972bf97cf95d72460f6f",function(e,t,i,n,r,a,o,s,c,d){var l={},f,u={}})

// 模块名称引用优先顺序命名方式
EDU(23,function(e,t,i){var l={},f,u={}})
  • core.js文件名称必变
    • 其他js文件是否改变具有不确定性

以上两点会引起:

  • js的改变导致模块html文件的内容改变(html引用js)
  • 模块html的改变导致页面ftl文件内容的改变(ftl引用config.ftl,模块html打包结果输出到config.ftl中)
  • ftl与html的改变,导致后端应用的ftl模版引擎缓存与vanish的html缓存失效

跟飞哥提过之后发版本更新打包工具、修改了打包方式为第三种之后就解决了,目前NEJ的第三种打包方式已经比较稳定,实现了文件名与文件内容同步修改。

2、ndp的部署流程

在本地开发完毕之后,需要部署到测试环境。目前基本上大多数产品都从omad迁移到了ndp。我们经常会遇到在本地是好的,一部署就出现问题,而且遇到服务器前端基本就鸡鸡了。但只要熟悉了ndp的整个部署流程,就算找后端GG帮忙排查问题也就变得so easy 了。
大家经常遇到的问题如下:

  • 本地打包正常,ndp上构建失败。
    其实常见的就那么几个原因:

    • 文件名引用大小写问题,因为linux是严格区分大小写的
    • 各种bower、npm安装的依赖库各种找不到
    • 各种bower安装的组件版本冲突
  • 构建部署成功,但是页面内容还没改变,各种怀疑人生,问题原因:

    • 代码没有push,包括工程、组件
    • npm、bower 安装使用了缓存,没有安装更新后的文件
    • 服务器缓存或本地缓存

以上问题的解决排查方法基本如下:

  1. ssh登录到ndp构建机器上
  2. 业务代码问题,查看pub文件夹下打包出来的文件,查找具体的错误(nej打包错误会列出具体文件名、行号、列号)
  3. 2的基础上如果发现是bower、npm依赖包的问题,那么可以npm cache clean | bower cache clean清掉缓存,再不行直接删掉node_modules文件夹以及bower的lib文件夹,再重新安装,还可以直接cd到具体文件查看文件内容是否更新。当然清缓存的操作可以直接在写脚本中,不需要登录编译机,但是并不建议每次都清缓存,因为这会使得发布时间变长。

以上的问题排查过之后,发现文件已更新、部署成功页面没变或者报错,基本可以去排查缓存、重启vanish了

下面是我从wiki找到的一张ndp部署的流程图,部署流程清晰明了,中间缺了几个前端的打包步骤与一些规定。

ndp上前端部署有一些规则需要遵守:
前端打包一般是打包到pub文件下

pub
├── h
│   └── web
├── s
│   └── web
└── v
    ├── edu
    └── web

然后拷贝到compressed文件夹下,compressed文件夹名称是必须的,因为服务器上的脚本已经固定名称了。

前端打包流程.jpg

通过以上的流程图,应该对ndp部署流程比较了解了吧。

ndp上的服务器大致分两种:

  1. 静态资源服务器,静态资源js、css、html部署在这些机器上
  2. 应用服务器,ftl部署在这些机器上

ndp的部署方式大概分两种:

  1. 一键部署, 一键部署完成的顺序具有不确定性,缓存问题的发生也往往在这个阶段。
    • ftl先完成,那么新版本ftl访问新版本静态资源404(采用第三种打包方式),静态资源服务器部署完成问题解决。新版本js请求模块的html文件(html采用第二种打包方式),就有可能造成vanish缓存错误了
  2. 分组控制部署, 默认的部署方式,可以控制顺序,保证静态资源先发布再发布ftl与后端应用。如果是先发布ftl并且使用nej第二种打包方式,就会出现新版本文件名缓存旧版本文件内容的问题,如果使用了第三种打包方式,就会出现静态资源访问不到的问题,等静态资源发布之后就好了。

3、后端服务器部署架构

对于后端服务器的部署,从挺兄那里了解到的一些信息,基本可以解释清楚我们遇到的问题,首先是整个后端服务的架构图。
后端服务器部署架构图.png

后端的服务器部署架构中有三块缓存。

  • ftl模版缓存
    freemark模版引擎默认设置缓存,后端应用会有路径指向ftl文件,当请求访问应用时读取ftl返回并把ftl内容缓存起来,但是ftl文件内容一改变,ftl模版引擎中的缓存就会被清掉,所以在前端工程每次发布的时候缓存都已经清掉了,不需要后端发布。

既然ftl的文件名称不会改变,使用第二种打包方式的话,ftl文件内容不变,那没有清缓存的必要,那么如果文件内容改变,缓存就自动清掉了,可是为啥会出现ftl缓存导致就页面访问到了新的静态文件的问题呢?当然,ftl的缓存不仅仅是发生在服务器端,也可能发生在浏览器或者运营商节点,确认过后端不会对ftl设置缓存,所以可能是浏览器本身问题或者运营商的缓存。

  • 静态资源服务器的nginx缓存
    静态资源服务器实现同名文件覆盖式部署(不是直接覆盖文件夹),不存在文件直接添加,所以文件夹下存在各个版本的静态资源文件。但是采用nej第二种打包方式的话,文件名一样,版本号不一样并没有用,在静态资源服务器上文件名称唯一。服务器上的nginx服务监测着这些文件的变化,文件一经操作,就会清掉nginx缓存中该文件的缓存,所以每次部署,基本都是清掉nginx缓存,且nginx缓存不处理文件名后的参数,把a.js?v=1与a.js?v=2当作一个文件处理

  • 静态资源的vanish缓存
    所有静态文件的缓存,缓存设置时间为1天,要清掉缓存只能重启,这样会影响到其他文件的缓存。vanish缓存处理文件版本参数,a.js?v=1与a.js?v=2的会认为是不同的文件,会同时缓存在内存中,操作不慎,容易在vanish缓存中出现新版本名称缓存了旧版本内容的情况。

以上是整个部署流程中涉及到的东西的一些简单阐述,如果有不对的地方,欢迎指正,下面会列出以前遇到的问题和出现问题的原因。以下问题的出现主要是使用NEJ第二种打包方式:固定名称?文件md5值格式,问题的出现主要涉及到前后端谁先发布的问题,也就是ftl与js、html谁先部署。请先认真看过后端服务器部署架构图再看以下问题

前后端谁先发布的问题

  1. 第一次发布上线之后静态资源服务器上存在core.js?v1, module2.js?v1与index.ftl,。module2引用的一些通用组件被打包在core.js中,如果版本不对应很有可能报错

  2. 第二次发布上线:
    前端先发
    那么此时静态静远服务器上存在core.js?v2, module2.js?v2。

    • 用户访问到了旧的ftl,且v1版本的资源在vanish缓存中未失效,vanish缓存返回v1版本文件。
      旧ftl访问vanish缓存中文件
    • 用户访问到了旧的ftl,且v1版本的资源在vanish缓存部分失效,vanish缓存返回部分v1版本文件,然后去nginx中获取同名v2版本文件返回,并在vanish缓存中设置以v1版本号为名而内容为v2版本的缓存,然后返回给客户端,此时客户端同时出现v1、v2文件,可能导致报错,导致ftl未更新前所有用户访问都报错。
      旧ftl访问部分旧版本文件,部分新版本文件
    • 用户访问到了旧的ftl,且v1版本的资源在vanish缓存全部失效,请求全部请求新的文件并在vanish中形成缓存,浏览器处理代码引用正常,新版本js新加了接口发送到后端服务,接口404
      ![旧ftl访问访问到新文件,新加接口404]

在后端服务部署完成之前,用户访问到的都是老的ftl,都会出现这样的情况,等待后端服务部署好了用户可以访问到新的ftl之后问题就解决了。还有可能有些用户由于浏览器本身缓存或者运营商缓存,访问到的还是老的ftl,这个只能由用户自己处理了

后端服务先发
那么此时静态静远服务器上存在旧版本core.js?v1, module2.js?v1,且vanish缓存中未存在v2版本缓存。

  • 用户访问到新版本ftl,ftl访问新版本静态资源,vanish缓存不存在,去静态资源服务器请求并在vanish缓存中设置以v2版本号为名而内容为v1版本的缓存,然后返回给客户端,且等待前端静态资源发布完毕后缓存依然存在,后续请求在vanish缓存失效之前都不会去静态资源服务器获取最新的文件,所以拿到的都是错误文件。且服务器端给客户端设置的静态资源缓存过期时间一般为7天,就算vanish缓存清掉了,如果用户不会强制刷行浏览器的话,那问题可就大了
    ![新ftl访问访问到旧的资源文件并形成vanish缓存]

以上问题的出现,都是在上线发布过程中发生的,现在的上线完成时间一般在几分钟到半个小时之间,这期间很大几率有用户访问的,当然如果可以严格控制部署的流程,可以大大降低问题发生的几率。

比如采取后端先发且分组的方式来发布的,流程为:
流量切换到2组-->1组部署-->静态资源部署-->流量切换到1组-->2组部署-->均衡流量,听说流量切换的速度挺快,所以这样可以大大降低问题发生的几率。当然,这也是存在问题的,流程复杂了不说,如果在流量高峰期,很可能1组服务器根本满足不了,会导致服务器全崩掉的,所以采取这种方式上线的都是在夜深人静的时候。

在现有开发流程中,为了保证上线后的稳定性,部署环境会有多套,且环境的切换方式是通过切换host的方式来实现的,新版本的发布前需要现在预发环境验证通过之后才能发布上线,所以需要在不同环境中来回切换。基本上网站的域名都会分为主域名与静态域名,即主域名访问后端应用ftl,静态域名访问静态服务器上的js、css、html等静态文件。

我们经常会遇到切换环境改host文件后并没有及时切换的问题,必须要等一会或者强刷DNS缓存之后才起作用。既然有两个域名的存在,我们从线上环境切换到预发环境时,也就可能出现域名切换不同步问题。情况如下:

  • 主域名已切换,静态域名未切换
    请求访问到预发新版的ftl,而静态资源为线上的旧版的静态资源,出现问题同后端服务先发布的情况。意味着预发环境的请求造成了线上的缓存,在线上vanish缓存中新的文件名缓存着旧的文件内容。这种情况发生以后,预发环境多刷几遍缓存等host切换过来就没问题了且在上线之前线上环境也是没有问题的。但在vanish缓存过期时间之内上线了,就会出现新的ftl以新文件名称访问到旧的静态资源内容的问题。也就是大家经常念叨的预发上好好的,线上为啥就出问题了
    预发新ftl访问线上资源造成线上vanish缓存中新版本文件名缓存旧文件内容

  • 主域名未切换,静态域名已切换
    那么访问到的ftl为线上旧的ftl,而访问到的静态资源为预发环境的静态服务器,虽然会造成在预发环境的vanish中旧版本文件名缓存新版本文件名的情况,但是等主域名切换过来之后问题就解决了,且不会对线上造成什么影响。
    线上旧ftl访问预发静态资源

以上问题的出现,其实主要是静态资源服务器保存文件名与vanish缓存对文件版本号处理不一致所造成的,要想解决服务器与vanish对文件处理方式一致问题,实现缓存及时更新,就需要实现文件名称与文件内容同步更新了。

NEJ的第三种打包方式,前后两次打包可以实现文件名与文件内容同步更新,所以经过两次打包上线之后,静态服务器上大概会出现core_v1.js, core_v2.js, module_v1.js, module_v2.js, module2_v1.js (module_v1前后打包内容不变,所以文件名不变),按照以上两个问题的方式来检验一下:

前端先部署

  • 旧版的ftl访问core_v1.js, 请求到达vanish缓存,core_v1.js存在返回,结果正常。

  • 旧版的ftl访问module_v1.js, 请求到达vanish缓存,module_v1.js缓存失效了不存在,那么请求到达静态服务器,找到且返回给vanish缓存,并形成缓存,结果正常。
    旧ftl访问旧静态资源,不会造成错误缓存

  • 后端先部署
    新版本ftl访问core_v2.js, 请求到达vanish缓存没有,请求到达静态服务器没有,返回404,客户端报错,等前端部署完成之后问题解决,不会造成什么影响
    新ftl访问新静态资源,前端未发布返回404

使用NEJ第三种打包方式后,第一个问题基本上解决 ,除了上线期间如果后端先部署会短暂出现问题,但是可以通过分组方式避免的,且对后续不会造成影响。

环境切换不完全问题

  • 主域名已切换,静态域名未切换
    那么访问到的ftl为预发新版的ftl,而访问到的静态资源为线上的旧版的静态资源,就是ftl访问core_v2.js, 请求到达线上vanish缓存没有,然后请求线上静态服务器都没有,返回404,切换完全后问题就解决,不会对线上缓存造成什么影响
    预发新ftl访问线上资源返回404,不造成缓存
    • 主域名未切换,静态域名已切换
      那么访问到的ftl为线上旧的ftl,而访问到的静态资源为预发环境的静态服务器,就是ftl访问core_v1.js,由于预发环境core_v1.js,core_v2.js同时存在,所以可以访问到core_v1.js文件,只是前端会烦恼明明都已经发布成功了,为啥文件还是旧的
      预发新ftl访问线上资源返回404,不造成缓存

对于问题二, 不管怎么切换不完全,都不会对线上造成什么影响,这是短暂对预发环境开发自己造成影响,所以出现这种问题时,记得检查自己的请求ip是否正确了。

NEJ的第三种打包方式与第二种相比,虽然同样会遇到短暂的访问错误问题,但是真实解决的问题是避免造成错误缓存问题,使用NEJ第三种打包方式,基本上可以解决目前常遇到的问题,NEJ的这种打包方式还是很好的,在处理服务器端缓存问题的同时,能做到文件名称随着文件内容同步更新,这就避免了如果只是小改变上下之后全部文件名改动造成全部缓存失效问题

在解决这个问题的过程中,还讨论、实施过其他的方法,比如我们的wap工程,采用了webpack打包,由于采用了ftl放在后端工程中前后端,前端只需要提供特定名称的分离方式,所以每次webpack打包出来的文件名称都是固定的,文件的版本号由后端回填加上文件名称后面当版本号

// ftl文件只有一个default.ftl, 属于多页面单模版模式
// ftl中的pageName后端根据url判断回填
// version由专门的后台页面配置,实时生效
<script src="${cdnUrl?default('')?html}/wap/s/${pageName?default('')?html}.js?v=${version?default('')?html}"></script>

发布顺序为后端先发布,然后前端发布,发布完成之后必须马上修改版本号。那如果发布顺序不固定会不会出现问题呢?答案是会的,可是缓存问题很容易得到解决,只需要再去升一下版本就好了。

那么会不会出现环境切换不完全的问题的呢,答案是会的,解决方法也很简单,去升级一下版本就好了。

这种方式优点很明显,就是清掉缓存很简单。同时也是缺点,如果预发环境需要多次发布,每次都需要手动去修改版本号,而且每次上线之后版本号都要改变,那么就会导致原本在vanish的缓存、在浏览器中的缓存全部失效。

还有环境切换不完全造成线上缓存错误问题,后端GG提议不同环境使用不同的域名的方式解决该问题,当然是可以解决的,而且切换环境就不需要切换host了,如果使用切换host的方式来切换环境的话,使用NEJ第三种打包方式也是可以解决这个问题的,所以还是看情况。

以上全部,可能还有其他奇怪的问题会出现(比如单独打开静态资源503,而在页面中可以访问到的奇怪问题,现场没保留,无法排查),本文说的不对的或者后续还有问题的,欢迎讨论。