캐싱을 사용하여 웹 팜에서 ASP.NET 앱 성능을 개선하는 방법
게시 됨: 2022-03-11컴퓨터 과학에는 캐시 무효화와 이름 지정이라는 두 가지 어려운 점이 있습니다.
- 저자: 필 칼튼
캐싱에 대한 간략한 소개
캐싱은 간단한 트릭을 통해 성능을 향상시키는 강력한 기술입니다. 결과가 필요할 때마다 비용이 많이 드는 작업(복잡한 계산 또는 복잡한 데이터베이스 쿼리와 같은)을 수행하는 대신 시스템에서 해당 작업의 결과를 저장하거나 캐시할 수 있습니다. 그 작업을 다시 수행할 필요 없이 다음에 요청할 때 이를 제공합니다(따라서 엄청나게 빠르게 응답할 수 있음).
물론 캐싱 이면의 전체 아이디어는 우리가 캐싱한 결과가 유효한 동안에만 작동합니다. 그리고 여기에서 문제의 실제 어려운 부분에 도달합니다. 캐시된 항목이 무효화되어 다시 생성해야 할 때를 어떻게 결정합니까?
분산 웹 팜 캐싱 문제를 해결하는 데 완벽합니다.
일반적으로 일반적인 웹 애플리케이션은 쓰기 요청보다 훨씬 더 많은 양의 읽기 요청을 처리해야 합니다. 그렇기 때문에 높은 로드를 처리하도록 설계된 일반적인 웹 응용 프로그램은 일반적으로 팜이라고 하는 웹 계층 노드 집합으로 배포되고 확장 가능하도록 설계되었습니다. 이러한 모든 사실은 캐싱의 적용 가능성에 영향을 미칩니다.
이 기사에서는 높은 로드를 처리하도록 설계된 웹 응용 프로그램의 높은 처리량과 성능을 보장하기 위해 캐싱이 수행할 수 있는 역할에 중점을 두고 있습니다. 저는 제 프로젝트 중 하나의 경험을 사용하여 ASP.NET 기반 솔루션을 제공할 것입니다. 삽화로.
고부하 처리 문제
내가 해결해야 했던 실제 문제는 원래 문제가 아니었습니다. 내 임무는 ASP.NET MVC 모놀리식 웹 응용 프로그램 프로토타입이 높은 로드를 처리할 수 있도록 하는 것이었습니다.
모놀리식 웹 애플리케이션의 처리량 기능을 개선하는 데 필요한 단계는 다음과 같습니다.
- 로드 밸런서 뒤에서 병렬로 웹 애플리케이션의 여러 복사본을 실행하고 모든 동시 요청을 효과적으로 처리할 수 있도록 합니다(즉, 확장 가능하게 만들기).
- 애플리케이션을 프로파일링하여 현재 성능 병목 현상을 파악하고 최적화합니다.
- 캐싱을 사용하여 읽기 요청 처리량을 늘리십시오. 이는 일반적으로 전체 애플리케이션 로드의 상당 부분을 구성하기 때문입니다.
캐싱 전략에는 캐시된 값을 저장하기 위해 Memcached 또는 Redis와 같은 일부 미들웨어 캐싱 서버를 사용하는 경우가 많습니다. 높은 채택률과 입증된 적용 가능성에도 불구하고 이러한 접근 방식에는 다음과 같은 몇 가지 단점이 있습니다.
- 별도의 캐시 서버에 액세스하여 발생하는 네트워크 대기 시간은 데이터베이스 자체에 도달하는 대기 시간과 비슷할 수 있습니다.
- 웹 계층의 데이터 구조는 기본적으로 직렬화 및 역직렬화에 적합하지 않을 수 있습니다. 캐시 서버를 사용하려면 이러한 데이터 구조가 직렬화 및 역직렬화를 지원해야 하며, 이를 위해서는 지속적인 추가 개발 노력이 필요합니다.
- 직렬화 및 역직렬화는 성능에 부정적인 영향을 미치는 런타임 오버헤드를 추가합니다.
이 모든 문제는 제 경우와 관련이 있으므로 다른 옵션을 찾아야 했습니다.
기본 제공 ASP.NET 메모리 내 캐시( System.Web.Caching.Cache )는 매우 빠르며 개발 중과 런타임에 직렬화 및 역직렬화 오버헤드 없이 사용할 수 있습니다. 그러나 ASP.NET 메모리 내 캐시에는 다음과 같은 단점도 있습니다.
- 각 웹 계층 노드에는 캐시된 값의 자체 복사본이 필요합니다. 이로 인해 노드 콜드 스타트 또는 재활용 시 더 높은 데이터베이스 계층 소비가 발생할 수 있습니다.
- 다른 노드가 업데이트된 값을 작성하여 캐시의 일부를 무효화하면 각 웹 계층 노드에 알려야 합니다. 캐시가 분산되고 적절한 동기화가 없기 때문에 대부분의 노드는 일반적으로 허용되지 않는 이전 값을 반환합니다.
추가 데이터베이스 계층 로드가 그 자체로 병목 현상을 일으키지 않는다면 적절하게 분산된 캐시를 구현하는 것이 처리하기 쉬운 작업처럼 보이죠? 글쎄요, 쉬운 일은 아니지만 가능합니다 . 제 경우에는 대부분의 작업이 웹 계층에서 발생했기 때문에 벤치마크 결과 데이터베이스 계층이 문제가 되지 않아야 함을 보여주었습니다. 그래서 ASP.NET 메모리 내 캐시를 사용하고 적절한 동기화를 구현하는 데 집중하기로 결정했습니다.
ASP.NET 기반 솔루션 소개
설명했듯이 내 솔루션은 전용 캐싱 서버 대신 ASP.NET 메모리 내 캐시를 사용하는 것이었습니다. 이를 위해서는 웹 팜의 각 노드가 자체 캐시를 갖고, 데이터베이스를 직접 쿼리하고, 필요한 계산을 수행하고, 결과를 캐시에 저장해야 합니다. 이렇게 하면 캐시의 인메모리 특성 덕분에 모든 캐시 작업이 매우 빠르게 진행됩니다. 일반적으로 캐시된 항목은 수명이 명확하며 일부 변경 또는 새 데이터 쓰기 시 오래된 상태가 됩니다. 따라서 웹 애플리케이션 로직에서 캐시 항목을 무효화해야 하는 시점은 일반적으로 명확합니다.
여기에 남은 유일한 문제는 노드 중 하나가 자체 캐시의 캐시 항목을 무효화할 때 다른 노드가 이 업데이트에 대해 알 수 없다는 것입니다. 따라서 다른 노드에서 서비스하는 후속 요청은 오래된 결과를 제공합니다. 이를 해결하기 위해 각 노드는 캐시 무효화를 다른 노드와 공유해야 합니다. 이러한 무효화를 수신하면 다른 노드는 단순히 캐시된 값을 삭제하고 다음 요청에서 새 값을 얻을 수 있습니다.
여기에서 Redis가 작동할 수 있습니다. Redis의 강력한 기능은 다른 솔루션과 비교하여 Pub/Sub 기능에서 나옵니다. Redis 서버의 모든 클라이언트는 채널을 만들고 채널에 일부 데이터를 게시할 수 있습니다. 다른 모든 클라이언트는 이벤트 기반 시스템과 매우 유사하게 해당 채널을 수신하고 관련 데이터를 수신할 수 있습니다. 이 기능은 노드 간에 캐시 무효화 메시지를 교환하는 데 사용할 수 있으므로 모든 노드는 필요할 때 캐시를 무효화할 수 있습니다.
ASP.NET의 메모리 내 캐시는 어떤 면에서는 간단하고 다른 면에서는 복잡합니다. 특히 키/값 쌍의 맵으로 작동한다는 점에서 간단하지만 무효화 전략 및 종속성과 관련된 많은 복잡성이 있습니다.
다행히도 일반적인 사용 사례는 충분히 간단하며 모든 항목에 대해 기본 무효화 전략을 사용하여 각 캐시 항목이 최대 단일 종속성을 갖도록 할 수 있습니다. 제 경우에는 캐싱 서비스의 인터페이스에 대해 다음과 같은 ASP.NET 코드로 끝냈습니다. (단순함과 독점 라이센스를 위해 일부 세부 정보를 생략했으므로 실제 코드가 아닙니다.)
public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); } 여기서 캐시 서비스는 기본적으로 두 가지를 허용합니다. 첫째, 어떤 값 getter 함수의 결과를 스레드로부터 안전한 방식으로 저장할 수 있습니다. 둘째, 요청 시 당시 값이 항상 반환되도록 합니다. 캐시 항목이 오래되거나 캐시에서 명시적으로 제거되면 값 getter가 다시 호출되어 현재 값을 검색합니다. 캐시 키는 주로 애플리케이션 전체에서 캐시 키 문자열의 하드 코딩을 피하기 위해 ICacheKey 인터페이스에 의해 추상화되었습니다.
캐시 항목을 무효화하기 위해 다음과 같은 별도의 서비스를 도입했습니다.
public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }종속 데이터 항목만 있던 데이터가 있는 항목을 드롭하고 키를 터치하는 기본 방법 외에도 일종의 "세션"과 관련된 몇 가지 방법이 있습니다.
우리 웹 애플리케이션은 종속성 관리를 위한 IoC(Inversion of Control) 디자인 패턴의 구현인 종속성 주입을 위해 Autofac을 사용했습니다. 이 기능을 통해 개발자는 종속성에 대해 걱정할 필요 없이 클래스를 생성할 수 있습니다. IoC 컨테이너가 부담을 관리하기 때문입니다.
캐시 서비스와 캐시 무효화기는 IoC와 관련하여 수명 주기가 크게 다릅니다. 캐시 서비스는 싱글톤(모든 클라이언트 간에 공유되는 하나의 인스턴스)으로 등록된 반면 캐시 무효화기는 요청당 인스턴스로 등록되었습니다(각 들어오는 요청에 대해 별도의 인스턴스가 생성됨). 왜요?

그 대답은 우리가 처리해야 하는 추가적인 미묘함과 관련이 있습니다. 웹 애플리케이션은 주로 UI와 로직 문제를 분리하는 데 도움이 되는 MVC(Model-View-Controller) 아키텍처를 사용합니다. 따라서 일반적인 컨트롤러 작업은 ActionFilterAttribute 의 하위 클래스로 래핑됩니다. ASP.NET MVC 프레임워크에서 이러한 C# 속성은 어떤 방식으로든 컨트롤러의 작업 논리를 장식하는 데 사용됩니다. 이 특정 속성은 새 데이터베이스 연결을 열고 작업 시작 시 트랜잭션을 시작하는 역할을 했습니다. 또한 작업이 끝나면 필터 속성 하위 클래스가 트랜잭션을 성공하면 커밋하고 실패하면 롤백하는 역할을 했습니다.
캐시 무효화가 트랜잭션 중간에 발생한 경우 해당 노드에 대한 다음 요청이 이전(여전히 다른 트랜잭션에서 볼 수 있음) 값을 캐시에 성공적으로 넣는 경쟁 조건이 있을 수 있습니다. 이를 방지하기 위해 모든 무효화는 트랜잭션이 커밋될 때까지 연기됩니다. 그 후 캐시 항목은 안전하게 축출되며 트랜잭션 실패 시 캐시 수정이 전혀 필요하지 않습니다.
그것이 캐시 무효화기의 "세션" 관련 부분의 정확한 목적이었습니다. 또한 이것이 요청에 바인딩되는 수명의 목적입니다. ASP.NET 코드는 다음과 같습니다.
class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... } 여기에서 PublishRedisMessageSafe 메서드는 메시지(두 번째 인수)를 특정 채널(첫 번째 인수)로 보내는 역할을 합니다. 사실, 드롭 및 터치를 위한 별도의 채널이 있으므로 각 채널에 대한 메시지 핸들러는 수신된 메시지 페이로드와 동일한 키를 드롭/터치해야 할 일을 정확히 알고 있었습니다.
까다로운 부분 중 하나는 Redis 서버에 대한 연결을 적절하게 관리하는 것이었습니다. 어떤 이유로든 서버가 다운되는 경우 애플리케이션은 계속해서 올바르게 작동해야 합니다. Redis가 다시 온라인 상태가 되면 애플리케이션은 원활하게 다시 사용을 시작하고 다른 노드와 메시지를 다시 교환해야 합니다. 이를 달성하기 위해 StackExchange.Redis 라이브러리를 사용했으며 결과 연결 관리 논리는 다음과 같이 구현되었습니다.
class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } } 여기서 ConnectionMultiplexer 는 기본 Redis와의 투명한 작업을 담당하는 StackExchange.Redis 라이브러리의 유형입니다. 여기서 중요한 부분은 특정 노드가 Redis에 대한 연결이 끊어지면 캐시 없음 모드로 폴백하여 어떤 요청도 오래된 데이터를 수신하지 않도록 하는 것입니다. 연결이 복원된 후 노드는 메모리 내 캐시를 다시 사용하기 시작합니다.
다음은 캐시 서비스( SomeActionWithoutCaching )와 이를 사용하는 동일한 작업( SomeActionUsingCache )을 사용하지 않는 작업의 예입니다.
class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } } ISomeService 구현의 코드 조각은 다음과 같습니다.
class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }벤치마킹 및 결과
캐싱 ASP.NET 코드가 모두 설정되면 기존 웹 응용 프로그램 논리에서 이를 사용할 때가 되었으며, 캐싱을 사용하기 위해 코드를 다시 작성하는 데 가장 많은 노력을 기울일 위치를 벤치마킹하면 편리할 수 있습니다. 벤치마킹할 운영상 가장 일반적이거나 중요한 몇 가지 사용 사례를 선택하는 것이 중요합니다. 그 후 Apache jMeter와 같은 도구를 두 가지 용도로 사용할 수 있습니다.
- HTTP 요청을 통해 이러한 주요 사용 사례를 벤치마킹합니다.
- 테스트 중인 웹 노드에 대한 높은 부하를 시뮬레이션합니다.
성능 프로필을 얻으려면 IIS 작업자 프로세스에 연결할 수 있는 모든 프로파일러를 사용할 수 있습니다. 제 경우에는 JetBrains dotTrace Performance를 사용했습니다. 올바른 jMeter 매개변수(예: 동시 및 요청 수)를 결정하기 위해 실험하는 데 얼마간의 시간이 소요된 후 성능 스냅샷 수집을 시작할 수 있게 되며 이는 핫스팟 및 병목 현상을 식별하는 데 매우 유용합니다.
필자의 경우 일부 사용 사례에서 전체 코드 실행 시간의 약 15%-45%가 명백한 병목 현상이 있는 데이터베이스 읽기에 소비된 것으로 나타났습니다. 캐싱을 적용한 후 대부분의 성능이 거의 두 배(즉, 두 배 빠름)했습니다.
결론
보시다시피 제 경우는 일반적으로 "바퀴의 재발명"이라고 불리는 것의 한 예처럼 보일 수 있습니다. 이미 널리 적용되는 모범 사례가 있는데 왜 새로운 것을 만들려고 애쓰나요? Memcached 또는 Redis를 설정하고 그대로 두십시오.
모범 사례를 사용하는 것이 일반적으로 최선의 선택이라는 데에는 동의합니다. 그러나 모범 사례를 맹목적으로 적용하기 전에 다음과 같이 자문해야 합니다. 이 "모범 사례"가 얼마나 적용 가능한가? 제 케이스에 잘 맞나요?
내가 볼 때 적절한 옵션과 절충안 분석은 중요한 결정을 내릴 때 필수이며 문제가 그렇게 쉽지 않았기 때문에 선택한 접근 방식이었습니다. 제 경우에는 고려해야 할 요소가 많았고, 당면한 문제에 대한 올바른 접근 방식이 아닐 수도 있는 일률적인 솔루션을 취하고 싶지 않았습니다.
결국 적절한 캐싱을 통해 초기 솔루션에 비해 성능이 거의 50% 향상되었습니다.
