在LNMPA中实现代理缓存proxy_cache

2015年在linode上了一款VPS至今,而且不知道当时怎么想的,选用的是LNMPA架构,不过确实稳定!服务器环境经过优化,在国内访问也基本在2s以内。相比基本600ms来比,确实相差有点大,但考虑到配置和国内外差异,也因为没有配置CDN,所以基本上还能接受。我所有WordPress都没有安装cache插件,不安装cache插件主要是不喜欢太依赖第三方工具。最近有点时间,将proxy cache配置上,速度直接到达300ms秒开。发现proxy cache对于外贸独立站或是博客来说是非常合适的,而且可以同时应用到所有的外贸独立站上,比单个站配置Cache插件更快,更直接,维护也更方便。先理一下优缺点,记录一下具体配置。

优缺点比较

proxy cache原理就是将所有静态,甚至动态数据直接输出为cache保存在内存或是硬盘上,当被访问时直接从cache中读取,以快速替代数据库查询和资源调配

优点

  • 显著提升响应速度:缓存命中时,响应直接从内存或磁盘读取,延迟可降低 90% 以上。
  • 大幅降低后端负载:缓存静态或半静态内容(如 HTML、图片、API 响应),避免重复请求后端,假设某接口 QPS 为 1000,缓存命中率 80%,则后端实际负载降至 200 QPS。
  • 增强服务可用性:若后端故障,可配置 proxy_cache_use_stale 继续返回旧缓存(如商品详情页),防止页面404。
  • 节省带宽成本:CDN 边缘节点使用 Nginx 缓存,减少回源流量,降低带宽消耗。
  • 精细灵活的缓存策略:按状态码缓存(proxy_cache_valid 200 302 10m;),按请求方法控制(proxy_cache_methods GET HEAD;)动态条件缓存(通过 proxy_cache_bypass 和 proxy_no_cache 跳过特定请求。)

缺点

  • 缓存一致性问题:比如,商品价格更新后,用户可能看到旧价格(缓存未失效)或是,动态内容(如用户会话数据)误缓存导致逻辑错误。解决方案是:设置合理的 Cache-Control 头或手动清除缓存(proxy_cache_purge)。
  • 内存与磁盘资源消耗:缓存 1GB 数据约占用 1.2GB 磁盘空间(含元数据),高并发下内存缓存(proxy_cache_path 的 keys_zone)可能成为瓶颈。解决方案是:使用多级缓存(如内存 + SSD),限制 max_size 防止磁盘写满。
  • 配置复杂度高:缓存键(proxy_cache_key)设计不当导致冗余或遗漏。未处理 Vary 头,导致不同设备/语言收到错误缓存。解决方案是:擅用分析调试工具或使用add_header X-Cache-Status $upstream_cache_status; 显示缓存命中状态来解决错误。
  • 缓存击穿与雪崩:热点 Key 失效瞬间,大量请求穿透到后端。或是大量 Key 同时过期,后端压力骤增。解决方案:复杂点使用互斥锁,通过 Lua 脚本(OpenResty)实现原子化缓存更新。简单点,随机化TTL,合理使用proxy_cache_valid 200 随机时间;

通常对于一般的外贸独立站或是博客来讲,配置并不是太复杂,也很难出现缓存击穿或雪崩的情况。而对于综合性或电商网站,他们有更好的缓存方案,也较少使用proxy_cache. 下面也记录一下在LNMPA上配置proxy_cache的具体方法。

配置环境

LNMPA环境是LNMP.org提供的独立主机生产环境的Shell程序。LNMPA即为Linux+Nginx+MySQL+PHP+Apache, 这是一种特殊于大众常用的LNMP架构之外的以Apche为后端PHP处理php_mod结构的apache2handler。因为LNMP使用的是PHP-FPM (FastCGI Process Manager),所以以下proxy_cache可能并不适用于LNMP结构中。LNMP组合的缓存为fastcgi_cache。

Proxy_Cache配置流程

在nginx.conf中的通用配置

proxy_cache_path /var/cache/nginx_proxy levels=1:2 keys_zone=proxycache:200m inactive=24h max_size=10g use_temp_path=off;
		proxy_cache_key "$host$request_uri";
		proxy_cache_methods GET HEAD;
		proxy_cache_min_uses 1;
		proxy_cache_valid 200 12h;
		proxy_cache_valid 301 24h;
		proxy_cache_valid 302 1h;
		proxy_cache_valid any 12h;
		proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;		
		proxy_cache_revalidate on;				
		proxy_cache_background_update on;
		proxy_cache_lock on;
		proxy_cache_lock_timeout 5s;
		proxy_cache_lock_age 50s;		
		proxy_headers_hash_max_size 1024;
		proxy_headers_hash_bucket_size 128;

重点记录下proxy_cache_key,尽量简洁一点比较好,"$host$request_uri";使用主机名+参数结合,即使VPS下有几个不同的网站,主机名也能很好地区分不同的缓存。当然,如果需要将不同网站的缓存分开存放,在需要在各个网站.conf下单独设置proxy_cache_path

设定动态条件缓存

因为我的站基本都是以WordPress为架构的外贸独立站,所以设置条件相同,wp-admin基本不用缓存。

map $sent_http_content_type $skip_cache_content_type {
			default         0;
			"~application/json" 1;
		}
		
		# Skip Configuring 
		map $request_uri $decoded_uri {
			default $request_uri;
			~%3F     ?;
			~%26     &;
			~%2B     +;
		}

		map $decoded_uri $skip_cache {
			default                  0;
			~^/wp-admin/             1;
			~^/wp-login\.php         1;
			~^/wp-cron\.php          1;
			~^/xmlrpc\.php           1;
			~^/admin-ajax\.php       1;
			~^/sitemap(_index)?\.xml 1;
			~^/form/[^/]+\.php       1;
			~^/page-(forms|slider|video)\.php 1;
			~^/search\.php           1;
			~^/wp-comments-post\.php 1;
			~*(^|&)(token|password)= 1;
			~*wp-postpass            1;
			~*wordpress_no_cache     1;
			~*woocommerce_items_in_cart 1;
			~*wp_woocommerce_session_ 1;
		}

		# Skip PROXY CACHE
		proxy_cache_bypass $arg_nocache $arg_comment $cookie_nocache $cookie_session $skip_cache_content_type $skip_cache;
		proxy_no_cache $http_authorization $skip_cache;

解说:

  • application/json数据不用缓存,因为JSON通常由API或AJAX产生,缓存会导致数据不一致
  • 不缓存~*wp-settings-/~*wordpress_logged_in_/~*wordpress_[a-f0-9]+,因为在WordPress页面中,都会产生此结构cookie,如果直接跳过此cookie不缓存,所有页面都不会缓存,直接BYPASS
  • 特殊一点就是要跳过~*(^|&)(token|password)=,这是针对密码或是token验证,必须不能缓存,缓存导致新旧数据不一致。

添加缓存点

上述提到的LNMPA架构与LNMP是不同的,所以它的缓存点就放在以下两个location中。

location @apache
        {
            internal;			
            proxy_pass http://127.0.0.1:88;
			proxy_cache proxycache;
            include proxy.conf;
        }

        location ~ [^/]\.php(/|$)
        {
            proxy_pass http://127.0.0.1:88;			
			proxy_cache proxycache;
            include proxy.conf;
        }

而以下根location是不需要放置的:

location /
        {
            try_files $uri @apache;			
        }

location /{} 只是 try_files,并不会直接代理到 Apache,因此不需要添加 proxy_cache;在 location @apache 里使用了 proxy_cache proxycache;,这样可以缓存静态页面(HTML、JSON等)。
重点:代理PHP请求通常不适合使用proxy_cache,因为PHP代码的执行结果通常是动态的。但我主要是外贸独立站,所有这些数据,内容都是保存在MySQL中,且是不会或很少有更新会变化的。但如果每次访问都要从数据中调用,必然需要更长的访问时间和占据更多的资源,为何不把这些不常变化的数据全部缓存,这就是为什么在location ~ [^/]\.php(/|$)添加proxy_cache,主要目的就是要将整个网站的PHP数据全部缓存。

手动清除缓存

因为我们已经在通用配置中设置proxy_cache_valid缓存时间,我把它设置了12h,也就是说,当你发布了新的内容,12小时内,读者是看不到的,因为你的缓存一直是旧的。所以这里就需要手动清除缓存,将新的内容也立刻缓存起来且能正常访问。
以下配置了两个手动清除缓存的方法,一是在前面直接刷新,二是在WP后台,使用手动清除缓存还必须要用到proxy_cache_purge命令,但这个默认不是在nginx内置模块中的,必然要安装动态模块来实现。

安装ngx_http_cache_purge_module模块

从https://github.com/nginx-modules/ngx_cache_purge/releases下载后 通过编译(--add-dynamic-module=/usr/src/lnmp2.1/src/ngx_cache_purge-2.5.3 --with-compat),make后,生成objs/ngx_http_cache_purge_module.so,make install后会自动拷贝文件cp /usr/src/lnmp2.1/src/nginx-1.26.3/objs/ngx_http_cache_purge_module.so load_module到/usr/local/nginx/modules/ngx_http_cache_purge_module.so;如果只是后期增加不make install,就要手动CP;最后在nginx.conf的event事件之前load_module load_module /usr/local/nginx/modules/ngx_http_cache_purge_module.so; 根据服务器环境不同,有可能在此位置/usr/lib/modules/ngx_http_cache_purge_module.so;

在ngx_http_cache_purge_module模块安装好后,然后就是在proxy-pass-php.conf或是nginx.conf下增加purge处理块

location /purge_cache
		{
			limit_req zone=purge burst=10 nodelay;
			valid_referers none blocked server_names;	
			if ($invalid_referer) {
				return 403 "Forbidden: Invalid Referer";
			}
			
			proxy_cache_purge proxycache "$arg_key";
			post_action @apache;
		}
		
		location /purge
		{		
			limit_req zone=purge burst=10 nodelay;
			valid_referers none blocked server_names;	
			if ($invalid_referer) {
				return 403 "Forbidden: Invalid Referer";
			}
	
			if ($http_x_purge_secret != 'colinqi') {
				return 403;
			}
			
			if ($arg_key = "") {
				return 400;
			}
			
			proxy_set_header If-Match "";
			proxy_set_header If-None-Match "";
			proxy_set_header If-Modified-Since "";
			proxy_set_header If-Unmodified-Since "";
			proxy_set_header If-Range "";

			access_log /var/log/nginx_purge.log purge_log;	
			proxy_cache_purge proxycache "$arg_key";
			
			add_header X-ProxyCache-Purge "$arg_key";
			
			error_page 404 @purge_not_found;
			error_page 500 @purge_error;
			return 200;
		}

在上述/purge_cache location中主要是用来处理前端JS刷新强制更新缓存的,JS代码如下:

(function () {
    'use strict';
	//v1
	const perf = window.performance;
	const loc = window.location;
	const doc = document;
	function getOrigin() {
	  return loc.origin || loc.protocol + '//' + loc.host;
	}
	const origin = getOrigin();
	const isForceRefresh = () => {
		// Modern browsers (Chrome 57+, Firefox 50+)
		if (perf.getEntriesByType) {
			const navEntries = perf.getEntriesByType('navigation');
			if (navEntries.length > 0 && navEntries[0].type === 'reload') {
				return true;
			}
		}
		// Old browsers (IE 10+, Safari 9+)
		if (perf.navigation) {
			return perf.navigation.type === 1;
		}
		// Referer detection with performance timing for fast reloads
		try {				
			//const isSameOrigin = doc.referrer.startsWith(origin);  //for better performance
			const isSameOrigin = doc.referrer.indexOf(origin) === 0; //for better compatibility
			const timing = perf.timing;
			const loadTime = (timing.loadEventEnd && timing.navigationStart) 
				? timing.loadEventEnd - timing.navigationStart 
				: Infinity;
			return isSameOrigin && loadTime < 2000;
		} catch (e) {
			return false;
		}
	};
	
	const purgeCache = async () => {		
		if (isForceRefresh()) {
			const loc = window.location;
			const currentUrl = loc.href;
			const purgeURL = new URL('/purge_cache', origin);
			const cacheKey = [loc.host, loc.pathname, loc.search].join('');
			const purgeURLWithParams = purgeURL + '?key=' + cacheKey;

			console.log("Generated cacheKey:", cacheKey);
			console.log(purgeURLWithParams);

			try {
				const response = await fetch(purgeURLWithParams, {
					method: 'GET',
					headers: {
						'Cache-Control': 'no-cache, no-store',
						'Pragma': 'no-cache'
					},
				});

				if (response.ok) {
					window.location.href = currentUrl;
				} else {
					console.error('Proxy Cache Purge Failed:', response.statusText);
				}
			} catch (error) {
				console.error('Proxy Cache Purge Failed:', error);
			}
		}
	};
	purgeCache();
})();

在上述/purge location中主要是用来处理WP后面通过PHP刷新强制更新缓存的,PHP代码如下:

// AJAX handlers
array_map(static fn($action) => add_action("wp_ajax_$action", "handle_ajax_$action"), [
    'purge_proxycache', 'cq_flush_cache', 'run_gc_collect_cycles', 'set_xdebug_gcstats', 'check_redis_socket'
]);

function handle_ajax_purge_proxycache() {
	// AJAX handlers - ProxyCache Purge
    if (!check_ajax_referer('cq_theme_colinqi_nonce', 'nonce', false)) {
        wp_send_json_error('Security check failed', 403);
    }
    
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Permission denied', 403);
    }

	$result = nginx_purge_cache();

    if ($result['success']) {
        wp_send_json_success([
            'message' => $result['message'],
            'headers' => $result['headers'],
        ]);
		
    } else {
        wp_send_json_error([
            'message' => $result['message'],
			'headers' => $result['headers'],
            'code'    => $result['code'],
        ], $result['code'] ?? 500);
    }
    
}
function nginx_purge_cache($key = null) {
    $key = $key ?? generate_proxy_cache_key();
	$purge_url = add_query_arg(['key' => $key], home_url('/purge'));		
	$response = wp_remote_get($purge_url, [
		'headers' => [
			'X-Purge-Secret' => 'colinqi',
		],
		'sslverify' => false,
	]);
	
	if (is_wp_error($response)) {
        return [
            'success' => false,
            'message' => 'Connection failed: ' . $response->get_error_message(),
            'code' => 500
        ];
    }
	
	//cq_debug("Purge URL: $purge_url");	
	// Check HTTP status code from Nginx
	$status_code = wp_remote_retrieve_response_code($response);
	$is_success = in_array($status_code, [200, 204]);
    $status_message = wp_remote_retrieve_response_message($response);
	$headers = wp_remote_retrieve_headers($response)->getAll();
    $body = wp_remote_retrieve_body($response);
	$data = json_decode($body, true);
    $url = $response['http_response']->get_response_object()->url;

	if ($is_success) {
		return [
			'success' => $is_success,
			'message' => $status_code.'('.$status_message.'): '.$url,
			'headers' => $headers,
			'code' => $status_code
		];
		
	} else {
		return [
			'success' => $is_success,
			'message' => $status_code.'('.$status_message.'): '.$url,
			'headers' => $headers,
			'code' => $status_code
		];
	}
}

function generate_proxy_cache_key($url = null) {
    $url = $url ?? home_url();
    $parsed = parse_url($url);
    $host = $parsed['host'] ?? '';
    $path = $parsed['path'] ?? '/';
    $query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
    return $host . $path . $query;
}

以上PHP代码执行是通过JS的AJAX来的,所以仍需要调用AJAX代码:

$('#ProxyCachePurge').on('click', function(event) {
			event.preventDefault();
			$('#ProxyCachePurgeResult').text('Purging Proxy Cache...');

			$.ajax({
				url: admin_ajax.ajax_url,
				type: "POST",
				dataType: 'json',
				data: {
					action: 'purge_proxycache',
					nonce: admin_ajax.nonce,
				},
				success: function(response) {
					if (response.success) {
						$('#ProxyCachePurgeResult').text(response.data.message);
						console.log(response.data);	
					} else {
						$('#ProxyCachePurgeResult').text('Error: ' + response.data);
					}
				},
				error: function(jqXHR, textStatus, errorThrown) {
					let errorMsg = '';                
					// Attempt to parse JSON error (from WordPress)
					try {
						const response = JSON.parse(jqXHR.responseText);
						errorMsg = response.data?.message || response.message || response.responseText;
					} catch (e) {
						// Fallback to plain text
						errorMsg = jqXHR.responseText || errorThrown || textStatus;
					}
					console.log(jqXHR);	
					$('#ProxyCachePurgeResult').text('errorThrown: ' + errorMsg);
				}
			});
		});

上述AJAX代码放在theme-extend.js中,同时要调用WP-AJAX处理程序

add_action('admin_enqueue_scripts', static function(string $hook): void {
    if ($hook !== 'tools_page_theme_extend') return;

    // Enqueue the theme settings script
    $src = get_template_directory_uri() . '/includes/module/theme-extend.js';
	$ver = file_exists($src) ? filemtime($src) : null ;
    wp_enqueue_script('theme-extend-js', $src, ['jquery'], $ver, true);
    wp_localize_script('theme-extend-js', 'admin_ajax', [
        'ajax_url' => admin_url('admin-ajax.php'),
        'nonce' => wp_create_nonce('cq_theme_colinqi_nonce')
    ]);
});

最后是WP后面PHP提交是通过button实现的

<div id="proxycache_status" class="cache-tab-content" style="display:none;">
			<h2>Proxy Cache</h2>
			<button id="ProxyCachePurge" class="button">ProxyCache Purge</button>
			<div id="ProxyCachePurgeResult"></div>
		</div>

The End.