试翻译Output Cache Improvements in Orchard 1.9
原文:http://www.ideliverable.com/blog/output-cache-improvements-in-orchard-1-9
Orchard1.9即将到来(我知道,已经“即将到来”5个月了,不过这次真的是要发布了)。Ideliverable 对其中的贡献就是对output cache 处理逻辑的重大翻修(重构?)。 它与原有的逻辑大大不同,所以我们有必要对这些改动深入说明,以便让需要的同学能够真正理解output cache 是如何工作的,以及如何把它用在自己的网站上。
TLDR(Too long didn`t read)(TLDR党)注意!这是一篇详细的长文章!!!
之前版本(1.9)的output cached 有一个很严重的性能问题,以下情况问题更甚:
- 你的网站使用 Orchard.OutputCache 模块(废话!能不用?)
- 你的网站上有些资源是数据库或CPU密集型的 像(page)。
- 网站上的资源的output cached 使用比较有限(短)的过期时间
- 网站高并发高PV(专业说法:网站的访问量的增加快于资源的生成速度?)
我们在一台每年二月到三月会出现访问峰值的主机上发现了这个问题。比较幸运的是,他们为这个峰值的到来已经做了充分的性能测试(用一种我后面将会极力推荐的方式),就是在这些负载测试的时候,我们发现当访问量达到一定水平的时候,网站照常挂掉。主要表现为三点:
- 响应时间很慢,CPU利用率很高。
- Ado.net 连接池被耗尽。
- 整个服务被拒绝
经过详细分析及查阅了Orchard.OutputCache的相关代码后,我们发现问题出在缓存逻辑的设计上。
那么问题是怎么产生的呢?让我们通过假设一个网站的page A来模拟问题是怎么一步一步的产生的。
- 假定page A 上有一定的内容,还有几个menu部件,projection 部件 (经过筛选的资源)。 因为这是一个重内容的页面(and because Orchard is not the fastest crayon in the box),属于CPU\数据库密集型,假设它需要一台空闲的服务器2秒才能渲染完毕。
- 假定我们网站现在非常繁忙,page A 每隔1秒有10次请求。
- 现在一点问题都没有,因为page A 都是通过 output cache 输出。网站可以哼着小曲顺畅的在服务器上溜达,每次请求都能毫秒级响应。
- 现在page A 的缓存过期了。
- 下一个对page A的请求进来了, 它发现在缓存中找不到page A,这时候网站就会发2秒重新生成一个page A。
- 100 毫秒后,下一个对page A的请求进来。这时候它仍然发现 page A不在缓存中,同上这又触发网站去再生成一个page A。现在我们有2个请求同时在争夺稀缺的CPU及数据库资源,这将导致一个结果,2个请求都要发2倍的时间(4秒)来完成资源的生成。page A 从再次放入缓存的预计时间从2秒增加到4秒。
- 对page A 的请求越来越多, 问题变得越来越严重。(2秒到现在N秒?)越多的请求让服务器生成page A的生成时间就越来越长。因为他们都在争夺有限的CPU和数据库资源。这将导致:甚至连新的请求进来都不可能(做同样的事)。这个就是反馈循环?指数上升效果(螺旋上升),滚雪球效应—“a dear child has many names” 我们瑞士的说法。 关键点:问题加剧了问题本身,问题变得越来越严重。
- 最好的情况,有一个请求终于生成了page A,把它放到了缓存里。其它不能以优雅的方式完成的,及你的网站终于恢复了(直到下次page A 在缓存里过期). 最糟的情况,网站瘫痪。
很自然的,当我发现问题出在哪里的时候,我开始着手解决。
几种解决方案
一个output cache 解决方案在上述情况下能够稳定工作,最少要采取以下3种措施中的1种:
- 防止对同一资源的多并发请求同时去生成该资源。让第一个请求去生成资源,把后续请求关进小黑屋(block\阻塞),直到资源生成。这就是解决这个问题的最实际的特效药?
- 引进一个“grace time” (宽限时间),资源从过期(在output cache中)到被从cache中移除之间的时间间隔。如果一个过期的资源存在于cache中,我们就不需要将那些后续的请求关进小黑屋,我们只需要简单把这些脏数据(过期的资源)给他们。这就更进一步的改善了这些被关进小黑屋的请求的响应时间。响应时间快了,网站请求资源的队列就相应短了,使用的线程(并行)数就更少。就我们以上的场景,将减少20个等待的请求(关进小黑屋)。
- 主动“预缓存”资源。主动刷新(更新)资源保证资源永远不会过期。
专业级别的缓存解决方案 像nginx , Varnish,也是采用以上策略中的一种或多种。
依我看,#1,#2 结合是Orchard最好的缓存方案。为什么? 它们都是在请求的同一个context(上下文)中,他们解决了100% 的问题。#3相对复杂,会给系统引入更多的不确定性(需要一些后台任务来针对不同的用户请求生成独立的资源)。此外,#3要有效进行,在服务器开始接收外部请求之前 需要有一个暖机的时间(warmup period)来“预缓存”所有的资源,否则在访问高峰的时候就会出现相同的问题。(我的理解:假定只预缓存部分资源,那么没缓存的资源在访问高峰的时候就会产生相同的问题。) 相比其它两个,#3唯一优点:对于那个请求到的是过期的资源(从而触发生成新资源)的请求响应会很快。Hardly a game-changer(没有改变游戏规则)。
所以,我决定为Orchard 1.9 实现前两种方案(投票委员会研究通过)。
实现过程
注意事项
在Orchard中设计新的逻辑,良辰要考虑应对如下几点挑战:
- output cache(还有其它的orchard模块) 的存储机制是可扩展的并基于provider(provider based)。由于 (继承?)底层的存储provider ,cache(应该是.net的cache) 本身可以处理判断缓存过期及移除(通过在把资源放入缓存的时候制定一个过期的策略)而不用通过Orchard来处理。因此,为了能够提供脏数据,Orchard 需要考虑(加)一个缓存的过期时间,这个时间早于在它的实际过期时间。
- 一边提供缓存数据的一边添加资源到缓存。这将使得为请求(no using staements 或 try/finally blocks are possible)加可靠的锁异常困难。必须细致考虑,如果一个请求失败了而第二部分永远不会执行? 很容易导致死锁如果良辰没有足够小心。
- 生成资源的时间是不固定的,而缓存的“宽限时间”也是任意的,太小的缓存过期时间将导致缓存过的内容频繁需要重新生成。太小的宽限时间导致关进小黑屋的请求更多。理想的情况下,这2个时间都是可配置的。这样是否需要更长或更短的时间都可以根据实际情况设置。
- Orchard 经常被发布在web farms中。缓存必须能够在各个farm 节点间同步(分布式缓存?)但.NET 的线程并不能同步(分布式)。因此,假定群节点呈现相同的内容(内容同步),我们要么需要使用数据库事务保证跨节点同步,要么要让每个节点的缓存独立开来?我最终决定:后者是一个完全可以接受的折衷方案,应被视为一种良性的竞争状态?(什么鬼啊)。
新的配置
因为资源的生成(渲染)时间不固定,我们就在缓存配置页增加宽限时间(带默认值),资源生成间隔(带默认值)的 配置,并且每个路由可以单独配置。如下图:

正如你所想的,你可以为路由的配置留空,他们就会默认使用全局的配置,你也可以设置为0来禁用该路由的缓存配置。文章后面我会给出在配置这些值的时候需要考虑的问题的建议。
考虑到一个事实,cache能够自己过期及清除。现在有两个和缓存项相关联的时间属性:
l ValidUntilUtc 表示缓存在Orchard的过期时间(Orchard认为的过期时间)。第一个请求对应资源的时间在这个时间之后,资源将会重新生成和缓存会被更新。这个属性cache 的存储时间加配置的缓存间隔生成时间而得到。(缓存存储时间是16点50分,配置的间隔是1分钟,那么这个时间就是16:51 。
l StoreUntilUtc指定用来表示缓存实际被实际移除的时间,它等于上一个ValidUnitUtc加上配置的grace time。 这个值实际就是底层缓存存储的过期时间。底层缓存的默认实现(也就是ASP.NET cache)会在这个时间把缓存项移除。
如下图,这两个值都可以在Statistics标签页下面找到。

新的缓存设计逻辑
基于这两个新的配置项,新的output cache 能够对于相同资源的并发请求执行同步,并给处于配置的宽限时间内的资源提供过期的数据。让我们来看看那它到底是如何工作地。
新的output cache 设计在Orchard.OutputCache 模块的Orchard.OutputCache.Filters.OutputCacheFilters类中, 按ASP.NET MVC的说法,这个类既是IActionFilter也是一个IResultFilter。为了输出缓存的目地,filter 类分别对OnActionExecuting,OnResultExcuted 方法施展了魔法。两个方法分开处理,每个又执行在独立的请求中, 我们在管理锁(线程锁)时要尤其小心。
用两个流程图来展示这两个方法是怎么工作的:
首先是OnActionExcuting 请求之前:

需要注意几点:
- 褐色的项表示请求的开始与结束。
- Fitler类维护一个“ConcurrentDictionary”(并发字典)。这个字典的key是缓存的key,值是一个锁对象。这个锁对象用来同步并发的请求(对这个key的缓存数据)。图中的橙色部分表示临界区, 在这个临界区内,一个请求持有一个锁对象。
- “request allowed for cache ?“ 步骤有一堆检查来保证请求是否能使用output cache。如果不能,output cache的相关处理将被忽略,请求的执行方式同没有启用output cache 一致。这些检查包括:
- Controller和Action上的OutputCacheAttribute
- 不缓存所有的Post请求
- 不缓存所有的管理页面请求
- 不缓存所有的子action
- 不缓存配置项中禁用outputcache 的请求
“compute cache key“ 步骤为确定所请求资源的一个唯一key。这个key不仅包含资源的鉴定信息,还有诸如租户名,方法参数,配置的查询参数,culture,请求头,请求是否授权等信息。
如果这个唯一的key在缓存中找到:
- n Filter类开始检查这个key有没有过期,(ValidUntilUtc是否过期)。如果过期了,filter会判断它是否在宽限时间内。没有过期,简单的把 cached 数据输出到客户端,请求结束。
- 假定过期的key在宽限时间内,filter会检查这个key对应的锁对象能否取到。如果不能,说明有请求已经在生成新的缓存数据。把过期的数据发送给客户端,请求结束。
- 如果能取到锁对象, filter 建立一个 response的 快照 执行请求的后面内容。
- Key在缓存中找不到:
- Filter首先尝试获取key的锁对象,如果成功这个锁20秒后失效。这个机制会导致阻塞当前请求直到新的缓存数据生成。而且没有过期数据。在锁对象失效时间内,请求不能成功的释放锁对象,这个请求outputcache处理被忽略。过期机制主要是用来(理论上有可能)防止有些请求释放锁失败。有了这个故障安全措施,就算真的锁对象释放失败了,对其它资源的请求也会照旧,而不会导致建一个无线长的队列来等待这个锁对象。
- 如果这个锁对象能被获取。Filter会重新检查缓存。(which would be the case if we waited for another request to render the item)。找到key, 锁释放,缓存数据发送到客户端。
- 缓存中仍然没有, filter 建立一个 response的 快照 执行请求的后面内容。
还有一些其它的意外情况,为了流程清晰,图中省略掉了。像“硬刷新“ 客户端强制生成新的缓存数据,而不管当前的缓存状态。
1 public void OnActionExecuting(ActionExecutingContext filterContext) {
2
3
4
5 Logger.Debug("Incoming request for URL '{0}'.", filterContext.RequestContext.HttpContext.Request.RawUrl);
6
7
8
9 // This filter is not reentrant (multiple executions within the same request are
10
11 // not supported) so child actions are ignored completely.
12
13 if (filterContext.IsChildAction) {
14
15 Logger.Debug("Action '{0}' ignored because it's a child action.", filterContext.ActionDescriptor.ActionName);
16
17 return;
18
19 }
20
21
22
23 _now = _clock.UtcNow;
24
25 _workContext = _workContextAccessor.GetContext();
26
27
28
29 if (!RequestIsCacheable(filterContext))
30
31 return;
32
33
34
35 // Computing the cache key after we know that the request is cacheable means that we are only performing this calculation on requests that require it
36
37 _cacheKey = ComputeCacheKey(filterContext, GetCacheKeyParameters(filterContext));
38
39 _invariantCacheKey = ComputeCacheKey(filterContext, null);
40
41
42
43 Logger.Debug("Cache key '{0}' was created.", _cacheKey);
44
45
46
47 // The cache key lock for a given cache key is used to synchronize requests to
48
49 // ensure only a single request is regenerating the item.
50
51 var cacheKeyLock = _cacheKeyLocks.GetOrAdd(_cacheKey, x => new object());
52
53
54
55 try {
56
57
58
59 // Is there a cached item, and are we allowed to serve it?
60
61 var allowServeFromCache = filterContext.RequestContext.HttpContext.Request.Headers["Cache-Control"] != "no-cache" || CacheSettings.IgnoreNoCache;
62
63 var cacheItem = _cacheStorageProvider.GetCacheItem(_cacheKey);
64
65 if (allowServeFromCache && cacheItem != null) {
66
67
68
69 Logger.Debug("Item '{0}' was found in cache.", _cacheKey);
70
71
72
73 // Is the cached item in its grace period?
74
75 if (cacheItem.IsInGracePeriod(_now)) {
76
77
78
79 // Render the content unless another request is already doing so.
80
81 if (Monitor.TryEnter(cacheKeyLock)) {
82
83 Logger.Debug("Item '{0}' is in grace period and not currently being rendered; rendering item...", _cacheKey);
84
85 BeginRenderItem(filterContext);
86
87 return;
88
89 }
90
91 }
92
93
94
95 // Cached item is not yet in its grace period, or is already being
96
97 // rendered by another request; serve it from cache.
98
99 Logger.Debug("Serving item '{0}' from cache.", _cacheKey);
100
101 ServeCachedItem(filterContext, cacheItem);
102
103 return;
104
105 }
106
107
108
109 // No cached item found, or client doesn't want it; acquire the cache key
110
111 // lock to render the item.
112
113 Logger.Debug("Item '{0}' was not found in cache or client refuses it. Acquiring cache key lock...", _cacheKey);
114
115 if (Monitor.TryEnter(cacheKeyLock, TimeSpan.FromSeconds(20))) {
116
117 Logger.Debug("Cache key lock for item '{0}' was acquired.", _cacheKey);
118
119
120
121 // Item might now have been rendered and cached by another request; if so serve it from cache.
122
123 if (allowServeFromCache) {
124
125 cacheItem = _cacheStorageProvider.GetCacheItem(_cacheKey);
126
127 if (cacheItem != null) {
128
129 Logger.Debug("Item '{0}' was now found; releasing cache key lock and serving from cache.", _cacheKey);
130
131 Monitor.Exit(cacheKeyLock);
132
133 ServeCachedItem(filterContext, cacheItem);
134
135 return;
136
137 }
138
139 }
140
141 }
142
143
144
145 // Either we acquired the cache key lock and the item was still not in cache, or
146
147 // the lock acquisition timed out. In either case render the item.
148
149 Logger.Debug("Rendering item '{0}'...", _cacheKey);
150
151 BeginRenderItem(filterContext);
152
153
154
155 }
156
157 catch {
158
159 // Remember to release the cache key lock in the event of an exception!
160
161 Logger.Debug("Exception occurred for item '{0}'; releasing any acquired lock.", _cacheKey);
162
163 if (Monitor.IsEntered(cacheKeyLock))
164
165 Monitor.Exit(cacheKeyLock);
166
167 throw;
168
169 }
170
171 }
请求之后,OnResultExcuted图:

解释:
- 从OnResultExcuted 图中我们知道,进程有可能执行在锁当中,所以响应的项也用橙色表示。
- 如果response的快照已经被建立。Filter 首先检查response是否允许缓存。如果不检查,有些代理服务器的缓存控制头部会包含在response中,有可能会阻止缓存输出。 这些检查包括:如果response为合法的缓存对象,吸入缓存中。
- 不是200状态的不缓存
- 路由设定为不缓存的项
- 发送通知消息的response。
- 缓存key被当前进程锁定,释放锁。
- 最终,response输出到客户端,请求结束。
1 public void OnResultExecuted(ResultExecutedContext filterContext) {
2
3 var captureHandlerIsAttached = false;
4
5
6
7 try {
8
9
10
11 // This filter is not reentrant (multiple executions within the same request are
12
13 // not supported) so child actions are ignored completely.
14
15 if (filterContext.IsChildAction || !_isCachingRequest)
16
17 return;
18
19
20
21 Logger.Debug("Item '{0}' was rendered.", _cacheKey);
22
23
24
25 // Obtain individual route configuration, if any.
26
27 CacheRouteConfig configuration = null;
28
29 var configurations = _cacheService.GetRouteConfigs();
30
31 if (configurations.Any()) {
32
33 var route = filterContext.Controller.ControllerContext.RouteData.Route;
34
35 var key = _cacheService.GetRouteDescriptorKey(filterContext.HttpContext, route);
36
37 configuration = configurations.FirstOrDefault(c => c.RouteKey == key);
38
39 }
40
41
42
43 if (!ResponseIsCacheable(filterContext, configuration)) {
44
45 filterContext.HttpContext.Response.Cache.SetCacheability(HttpCacheability.NoCache);
46
47 filterContext.HttpContext.Response.Cache.SetNoStore();
48
49 filterContext.HttpContext.Response.Cache.SetMaxAge(new TimeSpan(0));
50
51 return;
52
53 }
54
55
56
57 // Determine duration and grace time.
58
59 var cacheDuration = configuration != null && configuration.Duration.HasValue ? configuration.Duration.Value : CacheSettings.DefaultCacheDuration;
60
61 var cacheGraceTime = configuration != null && configuration.GraceTime.HasValue ? configuration.GraceTime.Value : CacheSettings.DefaultCacheGraceTime;
62
63
64
65 // Include each content item ID as tags for the cache entry.
66
67 var contentItemIds = _displayedContentItemHandler.GetDisplayed().Select(x => x.ToString(CultureInfo.InvariantCulture)).ToArray();
68
69
70
71 // Capture the response output using a custom filter stream.
72
73 var response = filterContext.HttpContext.Response;
74
75 var captureStream = new CaptureStream(response.Filter);
76
77 response.Filter = captureStream;
78
79 captureStream.Captured += (output) => {
80
81 try {
82
83 // Since this is a callback any call to injected dependencies can result in an Autofac exception: "Instances
84
85 // cannot be resolved and nested lifetimes cannot be created from this LifetimeScope as it has already been disposed."
86
87 // To prevent access to the original lifetime scope a new work context scope should be created here and dependencies
88
89 // should be resolved from it.
90
91
92
93 using (var scope = _workContextAccessor.CreateWorkContextScope()) {
94
95 var cacheItem = new CacheItem() {
96
97 CachedOnUtc = _now,
98
99 Duration = cacheDuration,
100
101 GraceTime = cacheGraceTime,
102
103 Output = output,
104
105 ContentType = response.ContentType,
106
107 QueryString = filterContext.HttpContext.Request.Url.Query,
108
109 CacheKey = _cacheKey,
110
111 InvariantCacheKey = _invariantCacheKey,
112
113 Url = filterContext.HttpContext.Request.Url.AbsolutePath,
114
115 Tenant = scope.Resolve<ShellSettings>().Name,
116
117 StatusCode = response.StatusCode,
118
119 Tags = new[] { _invariantCacheKey }.Union(contentItemIds).ToArray()
120
121 };
122
123
124
125 // Write the rendered item to the cache.
126
127 var cacheStorageProvider = scope.Resolve<IOutputCacheStorageProvider>();
128
129 cacheStorageProvider.Remove(_cacheKey);
130
131 cacheStorageProvider.Set(_cacheKey, cacheItem);
132
133
134
135 Logger.Debug("Item '{0}' was written to cache.", _cacheKey);
136
137
138
139 // Also add the item tags to the tag cache.
140
141 var tagCache = scope.Resolve<ITagCache>();
142
143 foreach (var tag in cacheItem.Tags) {
144
145 tagCache.Tag(tag, _cacheKey);
146
147 }
148
149 }
150
151 }
152
153 finally {
154
155 // Always release the cache key lock when the request ends.
156
157 ReleaseCacheKeyLock();
158
159 }
160
161 };
162
163
164
165 captureHandlerIsAttached = true;
166
167 }
168
169 finally {
170
171 // If the response filter stream capture handler was attached then we'll trust
172
173 // it to release the cache key lock at some point in the future when the stream
174
175 // is flushed; otherwise we'll make sure we'll release it here.
176
177 if (!captureHandlerIsAttached)
178
179 ReleaseCacheKeyLock();
180
181 }
182
183 }
这些修改让Orchard有更强的伸缩性。
整个设计完成,我们再一次对相同的站点进行负载均衡测试,我们期待性能有极大的改善,但是坦率的说结果还是让我们很困惑。


浙公网安备 33010602011771号