解决Sitecore静态资源CDN缓存问题:深入探究ASP.NET缓存机制与Cookie的关系

背景

在Azure Kubernetes环境部署的Sitecore网站中,我们遇到了一个棘手的静态资源缓存问题。尽管CSS和JS文件能被CDN正常缓存(返回TCP_HIT),但HTML文件始终返回TCP_MISS,导致性能下降和不必要的带宽消耗。

经过初步检查,我们发现CSS和HTML的响应头竟然完全相同:

# CSS响应头
HTTP/1.1 200 OK
Cache-Control: public, no-cache="Set-Cookie", max-age=31536000
Content-Type: text/css
Set-Cookie: website#lang=en; path=/; secure; SameSite=None
Set-Cookie: shell#lang=en; path=/; secure; SameSite=None
X-Static-CacheControl: true
...

# HTML响应头
HTTP/1.1 200 OK
Cache-Control: public, no-cache="Set-Cookie", max-age=86400
Content-Type: text/html
Set-Cookie: website#lang=en; path=/; secure; SameSite=None
Set-Cookie: shell#lang=en; path=/; secure; SameSite=None
X-Static-CacheControl: true
...

唯一的区别是HTML使用了较短的max-age(86400秒/1天),而CSS使用了更长的max-age(31536000秒/1年)。

这引发了一个关键问题:为什么相同的缓存头会导致CSS/JS文件和HTML文件有不同的缓存行为?

问题分析过程

第一次尝试:使用ASP.NET Cache API

我们首先尝试使用ASP.NET的Cache API来设置缓存头:

private void Context_PreSendRequestHeaders(object sender, EventArgs e)
{
    var context = HttpContext.Current;
    if (context?.Request?.Path == null) return;

    var cachePolicy = _validator.GetCachePolicy(context.Request.Path);
    if (cachePolicy.ShouldCache)
    {
        var response = context.Response;
        if (response?.Headers != null)
        {
            response.Headers.Remove("Cache-Control");
            response.Cache.SetCacheability(HttpCacheability.Public);
            response.Cache.SetMaxAge(cachePolicy.MaxAge);
            response.Headers.Add("X-Static-CacheControl", "true");
        }
    }
}

结果如下:

HTTP/1.1 200 OK
Cache-Control: public, no-cache="Set-Cookie", max-age=31536000
Content-Type: text/css
Set-Cookie: website#lang=en; path=/; secure; SameSite=None
Set-Cookie: shell#lang=en; path=/; secure; SameSite=None
X-Static-CacheControl: true
...

这个方法"部分"有效——成功设置了publicmax-age,但ASP.NET自动添加了no-cache="Set-Cookie"指令。

管理层认为这个no-cache="Set-Cookie"指令是问题所在,它会导致CDN无法正确缓存HTML文件。

第二次尝试:直接设置HTTP头

我们尝试绕过Cache API,直接设置HTTP头:

private void Context_PreSendRequestHeaders(object sender, EventArgs e)
{
    var context = HttpContext.Current;
    if (context?.Request?.Path == null) return;

    var cachePolicy = _validator.GetCachePolicy(context.Request.Path);
    if (cachePolicy.ShouldCache)
    {
        var response = context.Response;
        if (response?.Headers != null)
        {
            response.Headers.Remove("Cache-Control");
            string cacheControl = $"public, max-age={cachePolicy.MaxAge.TotalSeconds}, immutable";
            response.Headers.Set("Cache-Control", cacheControl);
            response.Headers.Add("X-Static-CacheControl", "true");
        }
    }
}

结果令人惊讶:

HTTP/1.1 200 OK
Cache-Control: private
Content-Type: text/html
Set-Cookie: website#lang=en; path=/; secure; SameSite=None
Set-Cookie: shell#lang=en; path=/; secure; SameSite=None
X-Static-CacheControl: true
...

不仅没能设置我们想要的值,反而整个Cache-Control被覆盖为private

深入ASP.NET源码

为了理解问题,我们查看了ASP.NET的HttpCachePolicy.cs源码,发现了一个关键机制:

internal void SetHasSetCookieHeader()
{
    Dirtied();
    _hasSetCookieHeader = true;
}

当ASP.NET检测到响应中有Set-Cookie头时,会调用此方法设置内部标记。根据不同的缓存设置方法,ASP.NET会有不同的行为:

  1. 使用Cache.SetCacheability(HttpCacheability.Public)时:
    保留public,但添加no-cache="Set-Cookie"指令

  2. 直接设置Headers["Cache-Control"]时:
    完全覆盖为private

这解释了为什么两种方法都没有完全解决问题。

为什么CSS/JS和HTML缓存行为不同?

最终发现,虽然CSS/JS和HTML响应头几乎相同(都有Cookie和no-cache="Set-Cookie"),但CDN对不同文件类型有不同处理策略:

  1. CSS/JS文件:被视为纯静态内容,某些CDN即使看到no-cache="Set-Cookie"也会缓存它们
  2. HTML文件:被视为可能包含个性化内容,CDN严格遵守no-cache="Set-Cookie"指令不进行缓存

这种区别处理解释了为什么具有相同响应头的不同文件类型会有不同的CDN缓存行为。

解决方案:阻止静态资源设置Cookie

既然确定了根本原因——Cookie的存在触发了ASP.NET的特殊缓存处理——解决方案就很明确:阻止静态资源响应中设置任何Cookie

实现:响应流过滤器

public class StaticFileCacheModule : IHttpModule
{
    private readonly IStaticResourceValidator _validator;
    private HttpApplication _app;

    public StaticFileCacheModule() : this(new StaticResourceValidator()) { }

    public StaticFileCacheModule(IStaticResourceValidator validator)
    {
        _validator = validator;
    }

    public void Init(HttpApplication context)
    {
        _app = context;
        context.PreSendRequestHeaders += Context_PreSendRequestHeaders;
    }

    private void Context_PreSendRequestHeaders(object sender, EventArgs e)
    {
        var context = HttpContext.Current;
        if (context?.Request?.Path == null) return;

        var cachePolicy = _validator.GetCachePolicy(context.Request.Path);
        if (cachePolicy.ShouldCache)
        {
            var response = context.Response;
            if (response?.Headers != null)
            {
                // 替换响应过滤器
                response.Filter = new CookieFilterStream(response.Filter, response);

                // 设置标记表示我们处理了这个请求
                response.Headers.Add("X-Static-CacheControl", "true");
            }
        }
    }

    public void Dispose()
    {
        if (_app != null)
        {
            _app.PreSendRequestHeaders -= Context_PreSendRequestHeaders;
            _app = null;
        }
    }
}


public interface IStaticResourceValidator
{
    CachePolicy GetCachePolicy(string path);
}

public class StaticResourceValidator : IStaticResourceValidator
{
    private static readonly HashSet<string> AssetExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) 
    {
        // javascripts
        ".js", ".mjs", ".json", ".jsonld", ".map",
        // styles
        ".css", ".less", ".sass", ".scss",
        // fonts
        ".woff", ".woff2", ".ttf", ".otf", ".eot",
        // images
        ".gif", ".jpg", ".jpeg", ".png", ".svg", ".ico", ".webp",
        ".avif", ".apng", ".cur", ".bmp", ".tiff", ".tif",
        // videos
        ".mp4", ".webm", ".ogv", ".ogg", ".mov", ".m4v",
        ".avi", ".mpg", ".mpeg", ".3gp",
        // audios
        ".mp3", ".wav", ".m4a", ".aac", ".flac", ".opus",
        // documents
        ".pdf", ".txt", ".rtf", ".csv",
    };

    private static readonly HashSet<string> HtmlExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
    {
        ".html", ".htm"
    };

    public CachePolicy GetCachePolicy(string path)
    {
        if (string.IsNullOrEmpty(path))
            return CachePolicy.NoCache;

        path = path.ToLower();
        var extension = System.IO.Path.GetExtension(path);

        // Check static html
        if (!string.IsNullOrEmpty(extension) && HtmlExtensions.Contains(extension))
        {
            return CachePolicy.StaticHtml;
        }

        // Check asset extension
        if (!string.IsNullOrEmpty(extension) && AssetExtensions.Contains(extension))
        {
            return CachePolicy.StaticAsset;
        }

        // Check known static paths
        if (path.StartsWith("/static/")
            || path.StartsWith("/assets/")
            || path.StartsWith("/_cms-site-content/")
            || path.StartsWith("/static-campaign/")
        )
        {
            return CachePolicy.StaticAsset;
        }

        return CachePolicy.NoCache;
    }
}

public class CachePolicy
{
    public bool ShouldCache { get; set; }
    public TimeSpan MaxAge { get; set; }
    public string CacheType { get; set; } = "asset";

    // Factory methods for common cache policies
    public static CachePolicy NoCache => new CachePolicy { ShouldCache = false };

    public static CachePolicy StaticAsset => new CachePolicy
    {
        ShouldCache = true,
        MaxAge = TimeSpan.FromDays(365),
        CacheType = "asset"
    };

    public static CachePolicy StaticHtml => new CachePolicy
    {
        ShouldCache = true,
        MaxAge = TimeSpan.FromDays(14),  // Shorter cache time for HTML
        CacheType = "html"
    };
}

public class CookieFilterStream : Stream
{
    private readonly Stream _innerStream;
    private readonly HttpResponse _response;
    private bool _cookiesRemoved;

    public CookieFilterStream(Stream innerStream, HttpResponse response)
    {
        _innerStream = innerStream;
        _response = response;
        _cookiesRemoved = false;
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        if (!_cookiesRemoved)
        {
            RemoveLanguageCookies();
            _cookiesRemoved = true;
        }

        _innerStream.Write(buffer, offset, count);
    }

    private void RemoveLanguageCookies()
    {
        try
        {
            // 获取所有Cookie
            var cookies = _response.Cookies;

            // 从后向前迭代,安全地删除Cookie
            for (int i = cookies.Count - 1; i >= 0; i--)
            {
                var cookieName = cookies[i].Name;
                var cookieValue = cookies[i].Value;

                if (cookieName != null)
                {
                    cookies.Remove(cookieName);
                    Sitecore.Diagnostics.Log.Info($"Remove cookies name: {cookieName}, value: {cookieValue}", this);
                }
            }

            // 手动设置Cache-Control头
            _response.Headers.Remove("Cache-Control");
            _response.Headers.Add("Cache-Control", "public, max-age=31536000, immutable");
        }
        catch (Exception ex)
        {
            Sitecore.Diagnostics.Log.Error($"Error removing cookies: {ex.Message}", ex, this);
        }
    }

    // Stream接口必要的实现
    public override bool CanRead => _innerStream.CanRead;
    public override bool CanSeek => _innerStream.CanSeek;
    public override bool CanWrite => _innerStream.CanWrite;
    public override long Length => _innerStream.Length;
    public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; }
    public override void Flush() => _innerStream.Flush();
    public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);
    public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
    public override void SetLength(long value) => _innerStream.SetLength(value);
}

解决方案原理

  1. 在响应发送前识别静态资源请求
  2. 添加自定义Stream过滤器,拦截响应
  3. 响应开始写入时删除所有Cookie
  4. 手动设置干净的Cache-Control头

特别注意:我们使用了从后向前的迭代方式删除Cookie,这是在循环中修改集合的最佳实践:

// 从后向前迭代,避免索引问题
for (int i = cookies.Count - 1; i >= 0; i--)
{
    var cookieName = cookies[i].Name;
    var cookieValue = cookies[i].Value;
    if (cookieName != null)
    {
        cookies.Remove(cookieName);
    }
}

部署结果

实施解决方案后,所有静态资源的响应头变为:

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Content-Length: 1245887
Content-Type: text/css
Last-Modified: Fri, 28 Mar 2025 15:17:36 GMT
X-Static-CacheControl: true
...

关键变化:
- 没有了Set-Cookie
- Cache-Control头中没有了no-cache="Set-Cookie"private指令
- 现在可以自由设置任何缓存控制指令

重要的是,所有静态资源(包括HTML文件)现在都能被CDN正确缓存,显示为TCP_HIT,大大提高了网站性能和用户体验。

关键发现与最佳实践

  1. ASP.NET针对含Cookie响应的特殊处理

    • 使用Cache API:添加no-cache="Set-Cookie"但保留其他设置
    • 直接设置Headers:完全覆盖为private(更严格的限制)
  2. Cookie触发安全机制

    • 这是ASP.NET的安全特性,防止含个人信息的响应被缓存
    • 不同的设置方法会触发不同的安全行为
  3. CDN对不同内容类型的差异处理

    • 即使响应头相同,CDN也会根据内容类型采用不同的缓存策略
    • HTML通常被视为可能含个性化内容,缓存判断更严格
  4. 最佳实践

    • 静态资源应完全无状态,不设置任何Cookie
    • 使用响应流过滤是修改ASP.NET响应的有效方法
    • 从后向前迭代是在循环中修改集合的安全方式

结论

这个问题虽看似简单,却涉及多层技术栈的复杂交互:
1. ASP.NET框架的内部缓存机制
2. HTTP协议的缓存安全特性
3. CDN对不同内容类型的差异处理
4. Sitecore的Cookie设置行为

通过深入理解问题本质,我们发现根源在于静态资源响应中不必要的Cookie触发了ASP.NET的安全机制。移除Cookie后,我们成功突破了ASP.NET严格的缓存控制限制,实现了所有静态资源的CDN缓存。

这个案例提醒我们:当框架行为出乎预料时,很可能是某种安全机制在起作用。理解这些机制是解决问题的关键。


感谢您阅读本文!如有问题或建议,欢迎讨论。

评论

还没有人评论,抢个沙发吧...

Viagle Blog

欢迎来到我的个人博客网站