Transaction : Out of shared memory, DeadLock (24.07.01)
Out of shared memory
자체적으로 개발 중인 Collector 로직에서, 시간에 따른 노후화된 시간 파티션을 제거하는 로직이 있다.
이것은 Scheduler와 엮여 매 설정된 시간마다 Drop 및 새로운 시간 파티션 테이블을 Create 하는데 문제가 발생했다.
에러를 보아하니,
1. 조건에 맞는 오래된 파티션 테이블이 다수이며 (로깅을 확인해 본 결과 약 200개 이상의 테이블로 사료된다. 이는 파티션 생성 로직만 냅두고 오랜만에 Drop을 하기 때문.. )
2. 이것을 삭제 실패하는 경고문을 확인할 수 있었다.
원인 : 메모리 부족
늘 그렇듯 트랜잭션을 통해 민감한 작업 실행 시 이를 묶어서 격리해야 하는데, 이것의 용량이 부족하다는 의미이다.
아마 새로운 테이블을 생성하는 작업은 상대적으로 단순하기에 몇백개도 버틴 듯 하지만, 삭제는 그보다 민감하고 데이터를 밀어버리는 만큼 메모리가 부족했던 것 같다.
(연관된 자원.. 인덱스나 공유 자원 등을 전부 잠궈야 하고 확인 후 검증해야 하니 무거운 것이 당연하다.)
https://postgresql.kr/docs/12/runtime-config-locks.html
PostgreSQL의 max_locks_per_transaction 기본값은 64로 설정되어 있다. 현재 따로 설정을 한 적이 없으니 64로 설정되어 있을 것이다.
이는 하나의 트랜잭션에서 최대 64개의 객체(테이블, 인덱스 등)에 잠금을 걸 수 있다는 의미이다. 따라서 한 번의 트랜잭션에서 많은 테이블을 드롭할 경우 이 제한을 초과할 수 있다.
다른 사수분께 여쭤본 결과 일반적인 경우, 한 번의 DROP TABLE 작업에서 드롭할 수 있는 테이블 수는 20~30개 정도라고 한다. 이는 테이블 외에도 인덱스나 다른 잠금 객체들이 포함될 수 있기 때문이다. 하지만, 실제로는 데이터베이스 환경과 설정에 따라 다를 수 있으므로 안전하게 접근하는 것이 좋다.
해결 : Batch 사용
한번의 IO에 트랜잭션 격리로 인한 메모리 부족이 발생한다면 결국 IO가 늘어나긴 하지만 데이터를 쪼개어 적은 메모리로도 해결 가능하게 해야 할 것이다.
이를 위해 Max Batch를 10으로 설정하겠다.
public class DropPartitionTable {
public static void run() {
//Drop은 항상 isInit false (현재 시간 테이블 지우면 안 됨)
String[] timeFormattingValue = TimePartitionFormatter.timeAdapter(PARTITION_SAVE_TYPE, false); //Query input에 쓰이는 타입 스트링 모음
String substrLen = timeFormattingValue[4]; //시간 변수 길이 ( 8, 10, 12 )
List<String> dropTableList = new ArrayList<>(); //테이블명 추출
try (
Connection conn = DataSourceInitializer.getConnection();
PreparedStatement selectPstmt = conn.prepareStatement(getSelectOldApmPartitionTable(substrLen));
) {
selectPstmt.setLong(1, Long.parseLong(timeFormattingValue[3])); // '.. < timeLimit' 조건
//1. 삭제할 테이블 조회해오기
try (ResultSet rs = selectPstmt.executeQuery()) {
while (rs.next()) {
dropTableList.add(rs.getString("partition_name"));
}
} catch (Exception e) {
ErrorLogger.log("Fail to Select Drop table List");
throw e;
}
if (dropTableList.isEmpty()) {
NoticeLogger.notice("[Scheduler][Drop] drop List is Empty");
return; //삭제할 것이 없음.
}
//2. 조회 후 삭제 로직, 배치 적용(Split)
for (int i = 0; i < dropTableList.size(); i += PARTITION_TABLE_BATCH_SIZE) {
List<String> splitDropTableList = dropTableList.subList(i, Math.min(i + PARTITION_TABLE_BATCH_SIZE, dropTableList.size()));
try (PreparedStatement dropSql = conn.prepareStatement(getDropOldTableQuery(splitDropTableList))) {
dropSql.execute();
} catch (Exception e) {
ErrorLogger.log("Fail to DropPartitionTable execute");
throw e;
}
}
} catch (SQLException e) {
ErrorLogger.log("DropPartitionTable", "runDropSql", e);
}
}
데드락 발생 : Insert, Drop 동시 작업 쓰기 락으로 추정.
2024-07-01 09:32:32 [main] WARN init.ErrorLogger - [====== ERROR LOGGER =======]
2024-07-01 09:32:32 [main] WARN init.ErrorLogger - [ERR LOG] Fail to DropPartitionTable execute
2024-07-01 09:32:32 [main] WARN init.ErrorLogger - [====== ERROR LOGGER WITH PRINTSTACKTRACE=======]
2024-07-01 09:32:32 [main] WARN init.ErrorLogger - [ERR LOG] DropPartitionTable
2024-07-01 09:32:32 [main] WARN init.ErrorLogger - [POSITION] runDropSql
org.postgresql.util.PSQLException: ERROR: deadlock detected
Detail: Process 28249 waits for AccessExclusiveLock on relation 2729588699 of database 734631; blocked by process 28278.
Process 28278 waits for RowExclusiveLock on relation 2729588767 of database 734631; blocked by process 28249.
Hint: See server log for query details.
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2675)
at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2365)
at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:355)
at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:490)
at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:408)
at org.postgresql.jdbc.PgPreparedStatement.executeWithFlags(PgPreparedStatement.java:167)
at org.postgresql.jdbc.PgPreparedStatement.execute(PgPreparedStatement.java:156)
at com.zaxxer.hikari.pool.ProxyPreparedStatement.execute(ProxyPreparedStatement.java:44)
at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.execute(HikariProxyPreparedStatement.java)
at db.insert.partition.DropPartitionTable.run(DropPartitionTable.java:51)
at Main.main(Main.java:19)
배치 작업 실행 중 데드락이 발생했다.
이것의 원인을 파악해보자. 크게 요약하자면 트랜잭션 락킹 과정에서 데드락이 발생한 것 같다.
Process 28278 waits for RowExclusiveLock on relation 2729588767 of database 734631; blocked by process 28249.
RowExclusiveLock은 보통 INSERT, UPDATE, DELETE 작업에서 발생한다.
Process 28278가 RowExclusiveLock을 기다리는 것으로 보아, 삽입이나 업데이트 작업이 진행 중일 가능성이 있다.
= 이는 현재 로드밸런서로 동작중인 Collector 2, 3 .. 등이 서버에서 데이터를 수집하여 저장하는 프로세스로 추론된다.
즉, 여러 수집기 인스턴스가 동일한 테이블을 건드린다는 것이다.
- 테이블 내 삽입 트랜잭션:
- RowExclusiveLock은 일반적으로 INSERT, UPDATE, DELETE 작업 중에 사용된다. 따라서 Process 28278가 테이블에 데이터를 삽입하려고 하는 중임을 나타낼 수 있다.
- 드롭 작업:
- AccessExclusiveLock은 DROP TABLE, TRUNCATE TABLE과 같은 작업에서 사용된다. Process 28249가 테이블 또는 파티션을 삭제하려고 할 때 이 잠금을 필요로 한다.
즉, 다음과 같이 요약할 수 있다.
- Process 28249: 테이블 또는 파티션을 드롭하려고 하며, 이를 위해 AccessExclusiveLock을 필요로 한다.
- Process 28278: 같은 테이블 또는 파티션에 데이터를 삽입하려고 하며, 이를 위해 RowExclusiveLock을 필요로 한다.
이에 대한 결과로 두 트랜잭션이 서로를 기다리며 교착 상태가 발생하였다.
교착 상태 해결
해결할 수 있는 방법을 생각해 본 결과 다음과 같았다.
- 재시도 메커니즘: 교착 상태가 발생했을 때 일정 시간 후 재시도하는 방법을 구현
- 작은 배치 크기: 한 번에 처리하는 테이블 수를 줄여 교착 상태 발생 가능성을 낮춘다.
- 트랜잭션 분리: DROP 또는 TRUNCATE 작업을 수행하기 전에 다른 트랜잭션이 완료되도록 대기
- 순서 일관성 유지: 모든 트랜잭션이 동일한 순서로 잠금을 요청하여 교착 상태를 피할 수 있도록 한다.
해결 방법 중 3, 4는 불가능하다. 로드 밸런싱 중이라 여러 인스턴스에서 각각 insert, drop을 시행하고 있기 때문이다.
정확히 말하자면 현재 세팅값 10초마다 한 번씩 batch insert가 발생한다. 데드락이 걸리기 좋은 환경으로 보인다.
1. FK 해제
대량의 데이터를 다루는 만큼 언젠가는 손봐야 한다고 생각했던 FK를 해제시켰다.
무결성을 포기하지만 성능 자체는 상승할 것이다. 사실 성능 향상보다는, 이것과 연결된 테이블이 같이 트랜잭션으로 묶이며 데드락의 발생 확률이 유의미하게 높아진다고 생각했다. 겸사겸사 Insert batch 성능도 높이고.
CREATE TABLE tbl_apm_trace_span_event
(
id bigserial,
span_id bigint,
span_created_at timestamp,
name varchar,
time_unix_nano bigint,
event jsonb,
PRIMARY KEY (id, span_created_at)
-- foreign key (span_id, span_created_at) references tbl_apm_trace_span (id, created_at)
) PARTITION BY RANGE (span_created_at);
그리고 이와 비슷한 확인했던 포스팅을 첨부한다.
AtomicBoolean 사용, 비동기 동시 접근 시 배제
/**
* 전역 변수
*/
public class PublicValue {
public static final AtomicBoolean isSchedulerRunning = new AtomicBoolean(false); //스케줄러 동작 유무 판단
}
스케줄러로 Drop 시행 시 Boolean 함수를 true 처리한다. 이를 통해 동일 인스턴스의 동시 Drop / Insert 작업을 직접 막을 수 있다.
이는 Quartz에서 트리거에 의한 삭제(Drop) 로직과 인스턴스 자체의 Insert 로직이 충돌하기 때문이다. (이 로직들은 각각 비동기로 동작했다.)
데이터 Loss를 방지하기 위해, 원 데이터는 임시 저장한다.
private static List<GaugeDto> keepedGaugeDtos = new ArrayList<>(); //스케줄링 기간동안 임시 저장된 Dto
private static List<SumDto> keepedSumDtos = new ArrayList<>(); //스케줄링 기간동안 임시 저장된 Dto
private static List<HistogramDto> keepedHistogramDtos = new ArrayList<>(); //스케줄링 기간동안 임시 저장된 Dto
//.. 파싱 로직
//분기점
if (isSchedulerRunning.get()) { //스케줄링 동작 중 (AtomicBoolean)
keepedGaugeDtos.addAll(gaugeDtos); //Insert 중지 후 임시 Keep
keepedSumDtos.addAll(sumDtos);
keepedHistogramDtos.addAll(histogramDtos);
} else { //스케줄링 비 동작중
//Keep Data 저장
gaugeDtos.addAll(keepedGaugeDtos); //옮겨담기
sumDtos.addAll(sumDtos);
histogramDtos.addAll(histogramDtos);
keepedGaugeDtos.clear(); //초기화
keepedSumDtos.clear();
keepedHistogramDtos.clear();
try {
//저장 로직 시행
} catch (Exception e) {
ErrorLogger.log("DataCaseDetailTable.insert", e);
throw e;
}
}