MySQL 파티션 자동 관리(SpringBoot+MybatisPlus)

1. 개발 환경

  • SpringBoot
  • MybatisPlus
  • MySQL

2. 개요

이 솔루션은 다음과 같은 기능을 제공합니다:

  1. @Scheduled 어노테이션을 사용한 일일 스케줄러를 통해 현재 데이터베이스의 모든 파티션 테이블을 조회합니다(여기서는 시간 범위로 파티셔닝).
  2. 남은 파티션 수가 설정된 예비 파티션 수보다 적은 경우(사용자 정의 예비 파티션이 없는 경우 기본 설정 사용), 필요한 파티션을 자동으로 생성하여 설정된 개수를 유지합니다.
  3. 존재하는 파티션 수가 설정된 보관 파티션 수보다 많은 경우(사용자 정의 보관 파티션이 없는 경우 기본 설정 사용), 초과된 파티션을 자동으로 삭제하여 설정된 개수를 유지합니다.

주요 목적은 일일 파티션 생성/삭제 운영 작업을 자동화하는 것입니다.
여기서 파티션 테이블은 대용량 텍스트 필드를 포함한 로그 레코드 데이터 저장에 주로 사용됩니다.
파티션 판단은 현재 시간을 기준으로 필요한 예비/보관 수량을 계산합니다.
파티셔닝을 통한 자동 정리를 통해 delete 작업으로 인한 데이터베이스 조각화율 증가 문제를 방지할 수 있습니다.

3. 파티션 테이블 소개

MySQL의 파티션 테이블은 대용량 테이블을 여러 소형 테이블로 분할하는 기술입니다. 논리적으로는 하나의 테이블이지만 물리적으로는 여러 독립적인 테이블입니다. 각 소형 테이블을 파티션이라고 하며, 서로 다른 물리적 위치에 배치되고 다른 스토리지 엔진을 사용할 수 있습니다.

3.1. 파티션 방식 종류

MySQL의 파티션 테이블은 다음 방식으로 분할할 수 있습니다:

  1. 범위 파티셔닝(RANGE partitioning): 특정 컬럼의 값 범위에 따라 데이터를 분할합니다. 예: 날짜 범위나 숫자 범위로 분할.
  2. 리스트 파티셔닝(LIST partitioning): 특정 컬럼의 값 목록에 따라 데이터를 분할합니다. 예: 국가나 지역 목록으로 분할.
  3. 해시 파티셔닝(HASH partitioning): 특정 컬럼의 해시 값을 기준으로 데이터를 분할합니다. 예: 사용자 ID의 해시 값으로 분할.
  4. 키 파티셔닝(KEY partitioning): 특정 컬럼의 키 값을 기준으로 데이터를 분할합니다. 해시나 범위 기반의 파티셔닝 방식입니다.

파티션 테이블 생성 시에는 파티션 키(partition key)를 지정해야 합니다. 파티션 키는 파티셔닝에 사용되는 컬럼이며, 하나 또는 여러 컬럼의 조합일 수 있습니다. 파티션 방식도 조합될 수 있으며, 파티션 키는 정수형, 날짜, 문자열 등 모든 데이터 타입이 될 수 있습니다.

3.2. 파티션 테이블의 장점:

  1. 쿼리 성능 향상: 병렬 처리를 통해 쿼리 효율성을 높입니다.
  2. 유지보수성 및 가용성 향상: 특정 파티션만 독립적으로 유지보수, 최적화, 복구가 가능하며 다른 파티션에 영향을 주지 않습니다.
  3. 분산 데이터베이스의 수평 확장 능력 개선: 데이터를 서로 다른 노드에 분산 배치하여 시스템의 확장성과 부하 분산 능력을 향상시킵니다.

3.3. 파티션 테이블의 단점:

  1. 관리 및 유지보수가 상대적으로 복잡: 파티션 설계와 분할 전략, 데이터 마이그레이션, 백업 및 복구 등을 고려해야 합니다.
  2. 쿼리 성능이 파티션 키에 제약: 쿼리 조건이 파티션 키를 포함할 경우 해당 파티션만 조회되므로 다른 파티션은 활용되지 않아 쿼리 성능이 저하될 수 있습니다.
  3. 추가적인 저장 공간 및 IO 오버헤드 발생 가능성: 각 파티션이 독립적인 테이블이므로 일정한 중복 저장 공간과 IO 오버헤드가 발생할 수 있습니다.

종합적으로 보면, MySQL의 파티션 테이블은 쿼리 성능, 유지보수성, 가용성을 향상시킬 수 있지만, 적절한 파티션 키와 분할 전략을 고려하고 추가적인 관리 및 유지보수 작업이 필요합니다.

4. 파티션 테이블 관리

4.1. 파티션 테이블 생성

CREATE TABLE 테이블명 (
    컬럼1 데이터타입,
    컬럼2 데이터타입,
    ...
)
PARTITION BY RANGE(파티션_컬럼) (
    PARTITION p1 VALUES LESS THAN (값1),
    PARTITION p2 VALUES LESS THAN (값2),
    ...
);

4.2. 파티션 테이블 수정

ALTER TABLE 테이블명 
ADD PARTITION (PARTITION 파티션명 VALUES LESS THAN (값));

4.3. 파티션 테이블 삭제

ALTER TABLE 테이블명
DROP PARTITION 파티션명;

4.4. 파티션 테이블 조회

SELECT
    TABLE_NAME AS tableName
FROM
    INFORMATION_SCHEMA.PARTITIONS
WHERE
    TABLE_SCHEMA = DATABASE()
    AND PARTITION_EXPRESSION IS NOT NULL
GROUP BY
    TABLE_NAME;

5. 자동 운영 기능 구현

스케줄러를 통해 일일 자동 추가/삭제 파티션 테이블 파티션

5.1. 설정 파일

# 파티션 추가 설정
table.partition.config.addEnable=true
table.partition.config.addMap.default=10
table.partition.config.addMap.zk_user_log_ext=10

# 파티션 정리 설정
table.partition.config.cleanEnable=true
table.partition.config.cleanMap.default=3
table.partition.config.cleanMap.zk_user_log_ext=3

5.2. 메인 진입점

스케줄러 PartitionScheduler

package com.example.scheduler;

import com.example.model.PartitionInfo;
import com.example.service.TablePartitionService;
import com.example.util.TimeUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ThreadPoolExecutor;

@Component
@ConfigurationProperties(prefix = "table.partition.config")
@Slf4j
public class PartitionScheduler {

    private static final String PART_PREFIX = "p";
    private static final String DEFAULT_CONFIG = "default";

    @Autowired
    private TablePartitionService tablePartitionService;

    @Resource(name = "partition-executor")
    private ThreadPoolExecutor executor;

    private boolean addEnable;
    private Map<String, Integer> addMap;
    private boolean cleanEnable;
    private Map<String, Integer> cleanMap;

    @Scheduled(cron = "0 1 0 * * ?")
    public void execute() {
        long startTime = System.currentTimeMillis();
        log.info("테이블 파티션 추가 및 정리 시작...");
        Date currentTime = new Date();
        List<PartitionInfo> partitionList = tablePartitionService.getAllPartitionTables();
        
        for (PartitionInfo partition : partitionList) {
            executor.execute(() -> {
                try {
                    if (addEnable) {
                        createNewPartitions(partition, currentTime);
                    }
                    if (cleanEnable) {
                        removeOldPartitions(partition, currentTime);
                    }
                } catch (ParseException e) {
                    throw new RuntimeException(e);
                }
            });
        }
        log.info("테이블 파티션 추가 및 정리 완료, 소요 시간:{}ms", System.currentTimeMillis() - startTime);
    }

    private void createNewPartitions(PartitionInfo partition, Date now) throws ParseException {
        Integer requiredDays = addMap.get(partition.getTableName());
        if (Objects.isNull(requiredDays)) {
            requiredDays = addMap.get(DEFAULT_CONFIG);
        }
        
        Date latestPartitionDate = TimeUtils.parseWithoutSeparator(partition.getMaxPartition());
        long remainingDays = TimeUtils.calculateDaysDifference(now, latestPartitionDate);
        
        if (remainingDays < requiredDays) {
            int partitionsToAdd = requiredDays - (int) remainingDays;
            for (int i = 1; i <= partitionsToAdd; i++) {
                Date newDate = TimeUtils.addDays(latestPartitionDate, i);
                String partitionName = PART_PREFIX + TimeUtils.formatWithoutSeparator(newDate);
                tablePartitionService.addPartition(
                    partition.getTableName(),
                    partitionName, 
                    TimeUtils.formatWithSeparator(TimeUtils.addDays(newDate, 1))
                );
                log.info("{} 테이블에 {} 파티션 추가 완료!", partition.getTableName(), partitionName);
            }
        }
    }

    private void removeOldPartitions(PartitionInfo partition, Date now) throws ParseException {
        Integer retentionDays = cleanMap.get(partition.getTableName());
        if (Objects.isNull(retentionDays)) {
            retentionDays = cleanMap.get(DEFAULT_CONFIG);
        }
        
        Date earliestPartitionDate = TimeUtils.parseWithoutSeparator(partition.getMinPartition());
        long existingDays = TimeUtils.calculateDaysDifference(earliestPartitionDate, now);
        
        if (existingDays > retentionDays) {
            int partitionsToRemove = (int) existingDays - retentionDays;
            for (int i = 0; i < partitionsToRemove; i++) {
                Date removeDate = TimeUtils.addDays(earliestPartitionDate, i);
                String partitionName = PART_PREFIX + TimeUtils.formatWithoutSeparator(removeDate);
                tablePartitionService.removePartition(partition.getTableName(), partitionName);
                log.info("{} 테이블에서 {} 파티션 삭제 완료!", partition.getTableName(), partitionName);
            }
        }
    }

    // getter/setter 생략
}

5.3. 파티션 테이블 작업 구현

5.3.1. TablePartitionService

package com.example.service;

import com.example.model.PartitionInfo;
import java.util.List;

public interface TablePartitionService {
    List<PartitionInfo> getAllPartitionTables();
    boolean addPartition(String tableName, String partitionName, String partitionValue);
    boolean removePartition(String tableName, String partitionName);
}

5.3.2. TablePartitionServiceImpl

package com.example.service.impl;

import com.example.mapper.PartitionMapper;
import com.example.model.PartitionInfo;
import com.example.service.TablePartitionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class TablePartitionServiceImpl implements TablePartitionService {

    @Autowired
    private PartitionMapper partitionMapper;

    @Override
    public List<PartitionInfo> getAllPartitionTables() {
        return partitionMapper.selectPartitionTables();
    }

    @Override
    @Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
    public boolean addPartition(String tableName, String partitionName, String partitionValue) {
        partitionMapper.createPartition(tableName, partitionName, partitionValue);
        return true;
    }

    @Override
    @Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
    public boolean removePartition(String tableName, String partitionName) {
        partitionMapper.deletePartition(tableName, partitionName);
        return true;
    }
}

@Retryable은 재시도 프레임워크로, 파티션 추가/삭제 시 테이블 잠금 획득 실패를 방지합니다.

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

5.3.3. PartitionMapper

package com.example.mapper;

import com.example.model.PartitionInfo;
import org.apache.ibatis.annotations.*;
import java.util.List;

@Mapper
public interface PartitionMapper {

    @Select("SELECT TABLE_NAME AS tableName, " +
            "max(right(PARTITION_NAME,8)) AS maxPartition, " +
            "min(right(PARTITION_NAME,8)) AS minPartition " +
            "FROM INFORMATION_SCHEMA.PARTITIONS " +
            "WHERE TABLE_SCHEMA = DATABASE() AND PARTITION_EXPRESSION IS NOT NULL " +
            "GROUP BY TABLE_NAME")
    List<PartitionInfo> selectPartitionTables();

    @Update("ALTER TABLE ${tableName} ADD PARTITION (PARTITION ${partitionName} VALUES LESS THAN (#{partitionValue}))")
    void createPartition(@Param("tableName") String tableName, 
                        @Param("partitionName") String partitionName,
                        @Param("partitionValue") String partitionValue);

    @Update("ALTER TABLE ${tableName} DROP PARTITION ${partitionName}")
    void deletePartition(@Param("tableName") String tableName, 
                        @Param("partitionName") String partitionName);
}

5.3.4. 엔티티 클래스

PartitionInfo

package com.example.model;

import lombok.Data;

@Data
public class PartitionInfo {
    private String tableName;
    private String maxPartition;
    private String minPartition;
}

5.3.5. 스레드 풀 설정

ThreadPoolConfiguration

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;

@Configuration
public class ThreadPoolConfiguration {

    @Bean("partition-executor")
    public ThreadPoolExecutor partitionExecutor() {
        return new ThreadPoolExecutor(
            10, 20, 10,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100),
            new CustomThreadFactory("partition-worker"),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }

    public static class CustomThreadFactory implements ThreadFactory {
        private final String poolName;
        private final AtomicInteger threadCounter = new AtomicInteger(1);

        public CustomThreadFactory(String name) {
            this.poolName = name;
        }

        @Override
        public Thread newThread(Runnable runnable) {
            Thread thread = new Thread(runnable);
            thread.setName(poolName + "-" + threadCounter.getAndIncrement());
            return thread;
        }
    }
}

5.3.6. 날짜 변환 유틸리티 클래스

TimeUtils

package com.example.util;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.Calendar;
import java.util.Date;

public class TimeUtils {

    private static final String DATE_FORMAT = "yyyy-MM-dd";
    private static final String DATE_FORMAT_NO_SEPARATOR = "yyyyMMdd";
    private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";

    public static Date parse(String dateString, String format) throws ParseException {
        return new SimpleDateFormat(format).parse(dateString);
    }

    public static Date parseWithSeparator(String dateString) throws ParseException {
        return parse(dateString, DATE_FORMAT);
    }

    public static Date parseWithoutSeparator(String dateString) throws ParseException {
        return parse(dateString, DATE_FORMAT_NO_SEPARATOR);
    }

    public static Date parseDateTime(String dateString) throws ParseException {
        return parse(dateString, DATETIME_FORMAT);
    }

    public static String format(Date date, String format) {
        return new SimpleDateFormat(format).format(date);
    }

    public static String formatWithSeparator(Date date) {
        return format(date, DATE_FORMAT);
    }

    public static String formatWithoutSeparator(Date date) {
        return format(date, DATE_FORMAT_NO_SEPARATOR);
    }

    public static String formatDateTime(Date date) {
        return format(date, DATETIME_FORMAT);
    }

    public static Date addDays(Date date, int days) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.add(Calendar.DAY_OF_MONTH, days);
        return calendar.getTime();
    }

    public static Date addHours(Date date, int hours) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.add(Calendar.HOUR_OF_DAY, hours);
        return calendar.getTime();
    }

    public static long calculateDaysDifference(Date startDate, Date endDate) {
        LocalDate start = startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
        LocalDate end = endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
        return ChronoUnit.DAYS.between(start, end);
    }

    public static long calculateSecondsDifference(Date startDate, Date endDate) {
        LocalDateTime start = startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
        LocalDateTime end = endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
        return Duration.between(start, end).getSeconds();
    }
}

5.4. 파티션 테이블 예제

user_operation_log_detail

CREATE TABLE `user_operation_log_detail` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '자동 증가 ID',
  `log_id` bigint NOT NULL COMMENT '운영 로그 ID',
  `content` mediumtext NOT NULL COMMENT '로그 상세 정보',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
  `log_date` date NOT NULL COMMENT '로그 날짜',
  `creator_id` bigint NOT NULL DEFAULT '0' COMMENT '생성자',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시간',
  `updater_id` bigint NOT NULL DEFAULT '0' COMMENT '수정자',
  `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부:0아니오;1예',
  `notes` varchar(255) NOT NULL DEFAULT '' COMMENT '비고',
  `version` bigint NOT NULL DEFAULT '0' COMMENT '버전 번호',
  PRIMARY KEY (`id`,`log_date`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='운영 로그 상세 테이블'
PARTITION BY RANGE COLUMNS(log_date)
(PARTITION p20240722 VALUES LESS THAN ('2024-07-23') ENGINE = InnoDB,
 PARTITION p20240723 VALUES LESS THAN ('2024-07-24') ENGINE = InnoDB,
 PARTITION p20240724 VALUES LESS THAN ('2024-07-25') ENGINE = InnoDB,
 PARTITION p20240725 VALUES LESS THAN ('2024-07-26') ENGINE = InnoDB,
 PARTITION p20240726 VALUES LESS THAN ('2024-07-27') ENGINE = InnoDB,
 PARTITION p20240727 VALUES LESS THAN ('2024-07-28') ENGINE = InnoDB,
 PARTITION p20240728 VALUES LESS THAN ('2024-07-29') ENGINE = InnoDB,
 PARTITION p20240729 VALUES LESS THAN ('2024-07-30') ENGINE = InnoDB,
 PARTITION p20240730 VALUES LESS THAN ('2024-07-31') ENGINE = InnoDB,
 PARTITION p20240731 VALUES LESS THAN ('2024-08-01') ENGINE = InnoDB,
 PARTITION p20240801 VALUES LESS THAN ('2024-08-02') ENGINE = InnoDB,
 PARTITION p20240802 VALUES LESS THAN ('2024-08-03') ENGINE = InnoDB,
 PARTITION p20240803 VALUES LESS THAN ('2024-08-04') ENGINE = InnoDB,
 PARTITION p20240804 VALUES LESS THAN ('2024-08-05') ENGINE = InnoDB);

여기서는 범위 파티셔닝을 사용합니다.
파티션 키: log_date

6. 향후 과제

태그: MySQL SpringBoot MyBatis-Plus database-partitioning scheduled-tasks

6월 1일 22:05에 게시됨