3장 - 성능을 좌우하는 DB 설계와 쿼리

인덱스

조회 트래픽을 고려한 인덱스 설계

TPS와 DB에 축적되는 데이터양 따라 적절하게 인덱스를 추가할 필요가 있다. 트래픽과 읽기 시간에 따라 풀스캔을 해도 괜찮을 수 있다.

왜 인덱스를 무작정 추가하는건 안좋을까?

  • 쓰기 성능 저하 (INSERT/UPDATE/DELETE 비용 증가)
    • INSERT 할 때마다 각 인덱스에도 항목을 추가
    • UPDATE 할 때 인덱스 컬럼이 바뀌면 기존 인덱스에서 제거 + 새로 추가
    • DELETE 도 인덱스에서 제거
  • 저장공간 + 메모리(버퍼 캐시) 낭비
    • 디스크 용량 증가
    • 중요한 데이터/핫 인덱스가 메모리에 못 올라가고 밀려남 → 캐시 미스가 늘어 읽기 성능이 떨어질 수 있음

버퍼 캐시 : DB는 디스크에서 페이지(블록)를 읽어오면 메모리(버퍼 캐시) 에 올려둔다. 다음에 같은 페이지가 필요하면 디스크 대신 메모리에서 가져온다.

 

전문 검색 인덱스 (Full Text Index)

텍스트 문자열 검색할 때 LIKEINSTR 을 사용할 수 있다.

  • INSTR(column, keyword)는 컬럼에 함수가 걸리는 조건이라 B-Tree 인덱스가 바로 쓰이기 어렵다.
  • LIKE ‘%keyword%’ 처럼 앞에 와일드카드가 붙으면 B-Tree 인덱스가 범위 탐색을 못하고 전체를 훑어야 함.
    • LIKE ‘keyword%’ 는 인덱스를 탈 수 있다.

MySQL에서는 이를 위해 FULLTEXT 인덱스를 지원한다.

  • stoplist — 긴 문장에 대해 인덱스를 생성해 크기가 커지기 때문에, 검색해도 의미가 거의 없는 단어는 인덱싱/검색 대상에서 제외하는 흔한 단어(stopword) 목록

(+ 엘라스틱 서치 : 문서를 토큰으로 쪼개서 역색인(inverted index)에 넣고, 그걸 여러 노드에 샤딩해서 분산 검색하는 엔진. Apache Lucene 기반)

 

선택도 (selectivity)

  • 조건절 기준 선택도선택도 = 조건을 만족하는 레코드 수 / 전체 레코드 수
    ⇒ 값이 작을수록(0에 가까울수록) 인덱스에 유리
    정의 : 어떤 조건(predicate)을 적용했을 때 전체 행 중 조건을 만족하는 비율
  • 카디널리티 기준 선택도⇒ 값이 클수록 인덱스에 유리
    선택도 = 카디널리티 / 전체 레코드 수

카디널리티(cardinality) : 컬럼에 존재하는 unique(distnct) 값 개수

선택도는 맥락에 따라 다르게 해석될 수 있음. 전체 중 얼마나 좁게 걸러지냐로 판단하면 됨.

그러면 선택도를 따졌을 때 인덱스 생성에 불리하면 인덱스로 사용하면 안되는가?

⇒ 그건 아니다. 적합한 상황도 있음. 예를 들면, 작업 큐. 상태가 W(대기), P(처리 중), C(완료)를 가질 때 대부분 C 상태를 가지기 때문에 큐를 처리하기 위해 status 컬럼을 인덱스로 사용해 W인 데이터를 조회하면 좋다.

 

커버링 인덱스

쿼리가 필요로 하는 컬럼들을 인덱스가 모두 가지고 있어서 테이블을 추가로 읽지 않고 인덱스만으로 결과를 만드는 인덱스/실행 방식

보통 B-Tree 인덱스로 조건에 맞는 행을 찾으면

  1. 인덱스에서 PK/rowid를 얻는다.
  2. 테이블에서 실제 row를 다시 읽어 필요한 정보를 가져온다.

근데 커버링 인덱스면 단계 2가 사라져 실행 시간이 빨라진다.

 

커서 기반 페이지네이션

보통 10개를 가져오려고 하면 커서 id 기준으로 10개를 가져온다. 근데 다음 페이지가 존재하는지 여부는 요청을 한번 더 보내야 한다. 이게 불필요한 자원 낭비가 될 수 있다.

책에서 소개하는 방식은 10개가 아닌 11개를 DB에서 읽어와 10개는 응답으로 주고 추가로 더 읽을 페이지가 있는지 여부를 추가로 제공하는 방식이다.

 

전체 개수 카운트

COUNT 함수는 조건에 해당하는 모든 데이터를 탐색해야 하기 때문에 성능 저하의 원인이다.

 

오래된 데이터 삭제 및 분리 보관하기

파티션 단위로 관리하는게 유리하다.

DELETE ... WHERE created_at < ... 는 보통

  • 많은 행을 스캔/락
  • 인덱스도 같이 정리(부하 큼)
  • MVCC면 vacuum/undo 부담
  • 논리 삭제, 재사용 공간으로 됨

파티션으로 하면

  • 파티션 자체를 DROP/TRUNCATE할 수 있음
  • 파티션 DROP은 대개 파일/세그먼트 자체가 제거되어 OS로 공간이 반환

 

단편화와 최적화

  • 레코드를 DELETE를 하면 → 디스크 용량 증가 X. 대신 삭제되었다는 표시(논리 삭제)만 해둠
    • 페이지 단위로 저장하고, 삭제는 공간을 반납하기보다 재사용 가능한 free space로 남겨둔다. 즉, 페이지에 내부 단편화가 생김
  • DB는 단편화 문제를 어떻게 해결하는가?
    • 페이지 단위 압축 → 페이지 내부 레코드 재배치하여 조각난 공간 합침
    • free space map → 빈 공간 많은 페이지 우선 사용
    • 인덱스/테이블 재구성

 

타입이 다른 컬럼 조인 주의

select u.userId, u.name, p.*
from user u, push p
where u.userId = 145
  and u.userId = p.receiverId
  and p.receiverType = 'MEMBER'
 order by p.id desc
 limit 100;

위 쿼리는 user 테이블의 userId 컬럼은 integer 타입, push 테이블의 receiveId 컬럼은 varchar 타입이라 두 컬럼의 타입이 서로 달라 인덱스를 제대로 활용하지 못한다.

receiverIdvarcharinteger랑 비교하려면 각 행마다 integer로 변환하는 작업이 필요하기 때문이다.

select u.userId, u.name, p.*
from user u, push p
where u.userId = 145
  and cast(u.userId as char character set utf8mb4) collate 'utf8mb4_unicode_ci' = p.receiverId
  and p.receiverType = 'MEMBER'
 order by p.id desc
 limit 100;

그래서 userIdvarchar로 캐스팅하면 딱 한 번만 계산되는 상수가 되기 때문에 receiveId가 제대로 인덱스를 탈 수 있다.

참고로 문자열을 비교할 때, 컬럼의 캐릭터셋도 같은지 확인해야 한다. 캐릭터셋이 다르면 그 자체로도 변환이 필요할 수 있다.

 


4장 - 외부 연동이 문제일 때 살펴봐야 할 것들

이 장은 주로 장애 전파, 트랜잭션 처리와 관련된 내용을 다룸

타임아웃

  • 연결 타임아웃 : 서버랑 연결(TCP connect) 자체를 시작해서 완료할 때까지 허용하는 최대 시간
  • 읽기 타임아웃 : 요청을 보낸 뒤, 응답 데이터가 오기 시작/도착할 때까지 기다리는 최대 시간
  • 소켓 타임아웃 : 연결이 된 뒤, 소켓에서 I/O가 한 번도 진행되지 않고 멈춰 있는 상태를 얼마나 허용할지. 즉, 패킷과 패킷 사이의 시간

 

재시도

재시도 가능 조건

  • 단순 조회
  • 연결 타임아웃
  • 멱등성을 가진 요청

재시도를 할 때는 2가지를 결정한다.

  • 재시도 횟수
  • 재시도 간격

 

Retry storm antipattern

어떤 장애/지연이 생겼을 때 클라이언트들이 동시에 재시도를 하는 바람에 원래의 장애보다 더 큰 부하를 만들어 시스템을 더 빨리/더 오래 죽이는 현상

 

처리량 제한

Bulkhead pattern

시스템의 자원(스레드/커넥션/큐 등)을 구획으로 분리(격리)해서 한 구성 요소의 장애가 다른 구성 요소로 전파되지 않게 만들어주는 패턴

 

토큰 버킷

아이디어

  • 버킷에 토큰이 일정 속도 r(토큰/초) 로 계속 쌓임(최대 용량 N)
  • 요청 1건(또는 데이터 n바이트)을 처리하려면 토큰 1개(또는 n개) 를 소비
  • 토큰이 부족하면 요청 거부

특징

  • 평균 처리율은 r로 제한됨
  • 토큰만 여유 있다면 Burst or request 가능 → 순간 처리량이 커질 수 있다.

 

누출 버킷

아이디어

  • 버킷에 요청을 담아둔다
  • 항상 일정한 속도 r로만 요청을 처리한다 (leak)
  • 버킷이 꽉 차면 새로운 요청은 거부

특징

  • 일정한 속도로 처리되기 때문에 출력 속도를 평탄화할 수 있다.
  • 버스트 방지

 

서킷 브레이커

상태

  • closed
  • half-open
  • open

(+ 서비스 이중화 참고 자료)

토스ㅣSLASH 24 - 대규모 사용자 기반의 마이데이터 서비스 안정적으로 운영하기

 

HTTP 커넥션 풀

서버와 맺는 TCP(그리고 HTTPS면 TLS) 연결을 미리 만들어두고 재사용해서, 매 요청마다 새로 연결하지 않게 해준다.

새 연결을 만들 때 비용

  • TCP 3-way handshake
  • (HTTPS) TLS handshake
  • 커널 자원/소켓 생성

HTTP/1.1 : keep-alive 유지

HTTP/2 : 연결 하나로 동시 처리(멀티플렉싱)