소개
이 글은 주로 캐시 사용 경험과 일상적인 프로젝트에서 직면하는 문제에 대해 이야기합니다.
디렉토리
1: 기본 작성 방법
2: Cache avalanche
1: 글로벌 잠금, 인스턴스 잠금
2: 문자열 잠금
3: 캐시 침투
4: 캐시 사태에 대해 이야기해보자
다섯 번째: 요약
1: 기본 글쓰기
데모의 편의를 위해 Runtime.Cache를 캐시 컨테이너로 사용하고 간단한 작업 클래스를 정의합니다. 다음과 같습니다:
public class CacheHelper { public static object Get(string cacheKey) { return HttpRuntime.Cache[cacheKey]; } public static void Add(string cacheKey, object obj, int cacheMinute) { HttpRuntime.Cache.Insert(cacheKey, obj, null, DateTime.Now.AddMinutes(cacheMinute), Cache.NoSlidingExpiration, CacheItemPriority.Normal, null); } }
간단한 읽기:
public object GetMemberSigninDays1() { const int cacheTime = 5; const string cacheKey = "mushroomsir"; var cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数 CacheHelper.Add(cacheKey, cacheValue, cacheTime); return cacheValue; }
프로젝트에는 이렇게 쓰는 방법이 많이 있습니다. 이렇게 작성해도 문제는 없지만 동시성 양이 늘어나면 문제가 발생합니다. 계속 읽기
둘: 캐시 눈사태
캐시 눈사태는 캐시 무효화(만료)로 인해 발생하며 새 캐시가 만료되지 않았습니다.
이 중간 기간 동안 모든 요청은 데이터베이스 쿼리로 이동하므로 데이터베이스 CPU와 메모리에 큰 부담이 가해지며 프런트 엔드 연결이 충분하지 않아 쿼리가 차단됩니다.
이 중간 시간은 SQL 쿼리의 1초에 전송 및 분석의 0.5초를 더해 그리 짧지 않습니다. 즉, 1.5초 내의 모든 사용자 쿼리는 데이터베이스에 직접 쿼리됩니다.
이 경우 우리가 가장 많이 생각하는 것은 locking과 queuing입니다.
1: 전역 잠금, 인스턴스 잠금
public static object obj1 = new object(); public object GetMemberSigninDays2() { const int cacheTime = 5; const string cacheKey = "mushroomsir"; var cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; //lock (obj1) //全局锁 //{ // cacheValue = CacheHelper.Get(cacheKey); // if (cacheValue != null) // return cacheValue; // cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数 // CacheHelper.Add(cacheKey, cacheValue, cacheTime); //} lock (this) { cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数 CacheHelper.Add(cacheKey, cacheValue, cacheTime); } return cacheValue; }
첫 번째 유형: 잠금(obj1)은 충족할 수 있는 전역 잠금이지만 각 기능에 대해 obj를 선언해야 합니다. 그렇지 않으면 함수 A와 B가 모두 obj1을 잠그면 둘 중 하나가 필연적으로 차단됩니다.
두 번째 유형: 잠금(this)은 현재 인스턴스를 잠그며 다른 인스턴스에는 유효하지 않습니다. 싱글톤 모드를 사용하여 잠글 수 있습니다.
그러나 현재 인스턴스에서는 함수 A가 현재 인스턴스를 잠그고 현재 인스턴스를 잠그는 다른 함수도 읽기 및 쓰기가 차단됩니다. 바람직하지 않음
2: 문자열 잠금
객체 잠금이 불가능하므로 문자열의 특성을 이용하여 캐시 키를 직접 잠글 수 있습니다.
public object GetMemberSigninDays3() { const int cacheTime = 5; const string cacheKey = "mushroomsir"; var cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; const string lockKey = cacheKey + "n(*≧▽≦*)n"; //lock (cacheKey) //{ // cacheValue = CacheHelper.Get(cacheKey); // if (cacheValue != null) // return cacheValue; // cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数 // CacheHelper.Add(cacheKey, cacheValue, cacheTime); //} lock (lockKey) { cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数 CacheHelper.Add(cacheKey, cacheValue, cacheTime); } return cacheValue; }
을 살펴보겠습니다. 첫 번째: 잠금(cacheName)은 문자열도 공유되고 이 문자열을 사용하는 다른 작업을 차단하므로 문제가 있습니다. 자세한 내용은 이전 블로그 게시물 멀티 스레딩의 C# 언어 잠금 시스템(1)을 참조하세요.
2015-01-04 13:36 업데이트: 문자열은 CLR(공용 언어 런타임)에 의해 유지되므로 전체 프로그램에서 특정 문자열의 인스턴스가 하나만 있음을 의미합니다. 그래서 우리는 두 번째 유형
을 사용합니다. 두 번째 유형인 잠금(lockKey)이면 충분합니다. 실제로 목적은 잠금의 최소 세분성과 전역 고유성을 보장하고 현재 캐시된 쿼리 동작만 잠그는 것입니다.
3: 캐시 침투
간단한 예: 일반적으로 사용자 검색 결과를 캐시합니다. 데이터베이스가 쿼리할 수 없으면 캐시되지 않습니다. 하지만 이 키워드를 자주 확인하게 되면 매번 데이터베이스를 직접 확인하게 됩니다.
이런 면에서 캐싱은 의미가 없으며, 이는 자주 제기되는 캐시 적중률 문제이기도 합니다.
public object GetMemberSigninDays4() { const int cacheTime = 5; const string cacheKey = "mushroomsir"; var cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; const string lockKey = cacheKey + "n(*≧▽≦*)n"; lock (lockKey) { cacheValue = CacheHelper.Get(cacheKey); if (cacheValue != null) return cacheValue; cacheValue = null; //数据库查询不到,为空。 //if (cacheValue2 == null) //{ // return null; //一般为空,不做缓存 //} if (cacheValue == null) { cacheValue = string.Empty; //如果发现为空,我设置个默认值,也缓存起来。 } CacheHelper.Add(cacheKey, cacheValue, cacheTime); } return cacheValue; }
예제에서는 쿼리할 수 없는 결과도 캐시합니다. 이렇게 하면 쿼리가 비어 있을 때 캐시 침투를 피할 수 있습니다.
물론 별도의 캐시 영역을 설정하여 1차 제어 검증을 수행할 수도 있습니다. 일반 캐시와 구별하기 위해서입니다.
四:再谈缓存雪崩
额 不是用加锁排队方式就解决了吗?其实加锁排队只是为了减轻DB压力,并没有提高系统吞吐量。
在高并发下: 缓存重建期间,你是锁着的,1000个请求999个都在阻塞的。 用户体验不好,还浪费资源:阻塞的线程本可以处理后续请求的。
public object GetMemberSigninDays5() { const int cacheTime = 5; const string cacheKey = "mushroomsir"; //缓存标记。 const string cacheSign = cacheKey + "_Sign"; var sign = CacheHelper.Get(cacheSign); //获取缓存值 var cacheValue = CacheHelper.Get(cacheKey); if (sign != null) return cacheValue; //未过期,直接返回。 lock (cacheSign) { sign = CacheHelper.Get(cacheSign); if (sign != null) return cacheValue; CacheHelper.Add(cacheSign, "1", cacheTime); ThreadPool.QueueUserWorkItem((arg) => { cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数 CacheHelper.Add(cacheKey, cacheValue, cacheTime*2); //日期设缓存时间的2倍,用于脏读。 }); } return cacheValue; }
代码中,我们多用个缓存标记key,双检锁校验。它设置为正常时间,过期后通知另外的线程去更新缓存数据。
而实际的缓存由于设置了2倍的时间,仍然可以能用脏数据给前端展现。
这样就能提高不少系统吞吐量了。
五:总结
补充下: 这里说的阻塞其他函数指的是,高并发下锁同一对象。
实际使用中,缓存层封装往往要复杂的多。 关于更新缓存,可以单开一个线程去专门跑这些,图方便就扔线程池吧。
具体使用场景,可根据实际用户量来平衡。