Apache HTTP Server 的 mod_proxy 与 mod_cache 组合是经典的反向代理缓存方案。当后端响应变慢、缓存目录频繁写入时,前端可能出现请求堆积,某些工作线程长时间处于锁等待状态,表现与死锁非常相似。Apache 本身基于多进程或多线程模型,而 Go 的 goroutine 调度器在处理锁竞争时也有类似阻塞表现。把 Apache 代理缓存的锁等待路径映射到 goroutine 模型,可以更清晰地还原死锁发生条件。文章会先用并发模型说明锁类型,再用一段 Go 示例复现循环等待,最后给出排查和优化建议。

一、Apache代理缓存的并发模型与锁类型
Apache 提供 prefork、worker、event 三种 MPM(多处理模块)。prefork 是纯进程模型,每个请求对应一个进程;worker 和 event 使用多线程处理,线程之间共享缓存结构。mod_cache 与 mod_cache_disk 在写入缓存时会创建多层锁。缓存条目可能被多个线程同时更新,因此需要对单个缓存文件加锁;为了维护缓存目录索引,还需要对目录结构加锁。如果线程 A 持有条目锁等待目录锁,线程 B 持有目录锁等待条目锁,就形成了经典的 ABBA 死锁。
以 mod_cache_disk 为例,写入流程大致包括:根据 URL 计算哈希、定位缓存文件、打开或创建文件、写入响应体、更新目录元数据。这个过程中,线程先持有条目级互斥锁,然后在必要时申请目录级文件锁。清理过期缓存的维护线程则可能先遍历目录并持有目录锁,再对找到的条目加锁。两类操作的加锁顺序相反,一旦同时发生,就会互相等待。Apache 的缓存实现虽然尽量避免了明显的嵌套锁,但在自定义模块或复杂配置下,锁顺序仍然可能失控。
Apache 使用的锁机制包括进程内互斥锁和基于 fcntl 或 flock 的文件锁。文件锁的作用域可以跨进程,在多个 Apache 子进程共享缓存目录时尤其重要。跨进程锁的调试比纯线程锁更困难,因为主进程、子进程和清理进程都可能参与锁竞争。使用 goroutine 模型可以将锁等待简化为同步原语的阻塞图,帮助开发者在设计阶段发现潜在问题。
二、用goroutine模型还原死锁链路
在 Go 程序中,两个 goroutine 分别按相反顺序获取互斥锁,很快就能触发死锁。下面的示例用两个互斥锁分别表示缓存条目锁和目录锁,cacheWrite 先拿条目锁再等目录锁,cacheEvict 先拿目录锁再等条目锁。两者同时运行时,循环等待条件成立。
package main
import (
"fmt"
"sync"
"time"
)
var (
entryLock sync.Mutex
dirLock sync.Mutex
)
func cacheWrite(wg *sync.WaitGroup) {
defer wg.Done()
entryLock.Lock()
defer entryLock.Unlock()
fmt.Println("cache write: holding entry lock")
time.Sleep(100 * time.Millisecond) // 模拟磁盘 I/O
dirLock.Lock()
defer dirLock.Unlock()
fmt.Println("cache write: acquired dir lock")
}
func cacheEvict(wg *sync.WaitGroup) {
defer wg.Done()
dirLock.Lock()
defer dirLock.Unlock()
fmt.Println("cache evict: holding dir lock")
time.Sleep(80 * time.Millisecond) // 模拟目录扫描
entryLock.Lock()
defer entryLock.Unlock()
fmt.Println("cache evict: acquired entry lock")
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go cacheWrite(&wg)
go cacheEvict(&wg)
wg.Wait()
fmt.Println("done")
}
这个例子虽然用 Go 编写,但反映的问题和 Apache 代理缓存中的多线程锁顺序问题完全一致。真实场景中,cacheWrite 可以对应 mod_cache 的写入线程,cacheEvict 对应缓存清理或过期检查线程。如果请求量大,写入线程频繁持有条目锁并等待目录锁释放,而清理线程长时间扫描目录,就容易出现瞬时死锁或长时间阻塞。即使没有真正死锁,锁竞争也会让工作线程数量快速上升,最终拖垮整个代理服务。
goroutine 模型还强调调度点的概念。Go 的抢占式调度在遇到 I/O 或锁阻塞时会切换,但逻辑上锁仍然被持有。Apache 的线程同样会在等待磁盘 I/O 或锁时让出 CPU,但锁不会释放。因此即使看起来线程并没有占满 CPU,死锁仍然存在。借助 goroutine 的阻塞分析,可以在代码审查阶段发现锁顺序问题,而不必等到线上事故。Go 自带的 race detector 和 pprof 也能帮助定位锁竞争,思路可以迁移到 Apache 的线程分析中。
三、排查Apache代理缓存死锁的步骤
线上遇到 Apache 代理缓存响应缓慢或请求超时,首先查看 mod_status 的输出。如果大量工作线程处于 W 状态(Sending Reply)或 K 状态(Keepalive),但 CPU 使用率不高,就要怀疑锁等待。接着通过 gdb 或 strace 附加到卡住的进程,观察线程是否阻塞在 pthread_mutex_lock、fcntl、flock 等系统调用。日志中如果连续出现缓存文件打开失败、目录创建失败等信息,也提示锁竞争严重。
还可以使用 Apache 的 mod_log_config 记录请求耗时,区分是后端响应慢还是缓存写入慢。如果后端响应时间正常,但缓存写入耗时异常,说明问题出在缓存磁盘层。此时检查缓存目录的层级和文件数量,目录文件过多会加剧锁竞争。通过调整 CacheDirLevels 和 CacheDirLength 可以把文件分散到更多子目录,减少单个目录上的锁冲突。
以下配置示例将缓存目录层级设为 3 层,每层长度 2,能有效分散缓存条目。同时建议限制最大缓存文件大小,避免大文件写入长时间占用锁。
# 调整缓存目录层级和长度,减少同一目录锁竞争 CacheRoot /var/cache/apache2/mod_cache_disk CacheDirLevels 3 CacheDirLength 2 CacheMaxFileSize 1000000 CacheMinFileSize 1 CacheDefaultExpire 3600 CacheIgnoreCacheControl On
另一个常被忽略的点是缓存过期清理周期。mod_cache 默认的清理任务会扫描整个缓存目录,如果缓存规模很大,扫描期间目录锁被持续占用,写入线程全部阻塞。可以设置 CacheLock 和 CacheLockPath 来控制锁文件位置,或者将缓存迁移到独立的快速磁盘,降低 I/O 延迟。从 goroutine 模型的角度看,缩短临界区、统一加锁顺序、减少嵌套锁,是避免死锁的根本手段。对于关键路径上的锁操作,应该像审查 Go 并发代码一样,逐条核对锁的获取与释放顺序。
Apache代理缓存死锁分析goroutine修改时间:2026-08-19 15:36:08