click-me 라는 프로젝트를 만들고 있다.
github readme에 들어가는 svg 이미지를 만들어주는 프로젝트이다.
해당 이미지를 클릭하면 api가 호출되고 이미지에 해당하는 nickname을 찾아 클릭 카운트를 증가시킨다.
클릭 카운트를 저장하는 곳은 일단은 redis이다. redis의 sorted sets을 활용하여 실시간 랭킹도 손쉽게 구현할 수 있다.
다만 이 저장된 데이터를 mysql에 동기화 시켜주는 작업이 문제였다.
일단 처음으로 간단하게 구현한 방식은 스케줄러로 2시간마다 mysql로 업데이트 하는 방식이였다.
@Service
@Conditional(RedisConnectionCondition.class)
public class DataTransferService {
private static final int BATCH_SIZE = 1000;
private static final String KEY = "clicks";
private final ZSetOperations<String, String> zSetOperations;
private final MemberRepository memberRepository;
public DataTransferService(final RedisTemplate<String, String> redisTemplate,
final MemberRepository memberRepository) {
this.zSetOperations = redisTemplate.opsForZSet();
this.memberRepository = memberRepository;
}
@Scheduled(fixedRate = 7200000)
@Transactional
public void transferData() {
long startIndex = 0;
while (true) {
List<TypedTuple<String>> tuples =
new ArrayList<>(zSetOperations.rangeWithScores(KEY, startIndex, startIndex + BATCH_SIZE - 1));
if (tuples.isEmpty()) {
break;
}
List<Member> members = createMembers(tuples);
memberRepository.saveAll(members);
startIndex += BATCH_SIZE;
}
}
private List<Member> createMembers(List<TypedTuple<String>> tuples) {
return tuples.stream()
.map(tuple -> new Member(tuple.getValue(), tuple.getScore().longValue()))
.toList();
}
}
위에 코드를 살펴보면 redis의 sorted sets에 저장된 nickname과 클릭카운트를 가져와서 Member를 생성해서 1000개씩 저장했다. 1000개씩 나눠서 가져오는 이유는 자칫 redis에 많은 데이터가 있어서 한번에 메모리상에 가져왔다가 Out of Memory가 발생할 수 있기 때문이다.
JPA에서는 @Transactional
애노테이션이 붙은 transferData()
메소드가 종료하고 트랜잭션이 커밋되는 시점에 실제 DB 쿼리가 실행된다.
그런데 단건의 쿼리가 여러개 나가는 것보다는 멀티 insert로 나가는게 성능에서 더 좋다. JPA에서도 Batch Insert를 사용해서 성능을 개선할 수 있다.
# 단건
insert into member (ninkname, click_count) values (?, ?)
# 멀티
insert into member (nickname, click_count) values
("seungpang1", 1),
("seungpang2", 2),
("seungpang3", 3)
insert rows를 여러개 연결해서 한번에 입력하는 방식을 Batch Insert라고 한다.
그렇다면 JPA에서는 Batch Insert를 어떻게 사용할까?
위에 코드에도 나와있듯이 memberRepository.saveAll(members)를 이용해서 batch Insert를 진행한다. JPA 기반의 Batch Insert를 진행할 때 별다른 코드가 필요가 없다.
컬렉션 자체를 saveAll()
로 저장하는게 끝이다.
하지만 설정파일에 몇가지 추가해야 할 부분들이 있다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/clickme?rewriteBatchedStatements=true
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
properties:
hibernate:
format_sql: true
batch_size: 50
order_inserts: true
order_updates: true
여기서 주목해야할 부분은 rewriteBatchedStatements=true
와 batch_size: 50
과 이다.
MySql에서 기본 설정은 false여서 true로 지정해줘야 한다.
이 옵션은 대량의 SQL 문을 한 번에 처리할 때, 서버 측에서 내부적으로 SQL 문을 다시 작성하여 성능을 최적화하는 데 도움을 준다.
batch_size는 Batch Insert에 대한 size값이다.
rows를 200 저장한다고 하면 4번에 걸쳐 insert를 진행하는 것이다.
insert into member (click_count,nickname) values (0,'819'),(0,'82'),(0,'820'),(0,'821'),(0,'822'),(0,'823'),(0,'824'),(0,'825'),(0,'826'),(0,'827'),(0,'828'),(0,'829'),(0,'83'),(0,'830'),(0,'831'),(0,'832'),(0,'833'),(0,'834'),(0,'835'),(0,'836'),(0,'837'),(0,'838'),(0,'839'),(0,'84'),(0,'840'),(0,'841'),(0,'842'),(0,'843'),(0,'844'),(0,'845'),(0,'846'),(0,'847'),(0,'848'),(0,'849'),(0,'85'),(0,'850'),(0,'851'),(0,'852'),(0,'853'),(0,'854'),(0,'855'),(0,'856'),(0,'857'),(0,'858'),(0,'859'),(0,'86'),(0,'860'),(0,'861'),(0,'862'),(0,'863')
위의 쿼리가 hibernate.jdbc.batch_size: 50
으로 지정한 결과다. 그러면 batch_size를 크게 하면 무조건 좋을까?
결론부터 말하면 답은 아니다.
그 이유는 공식문서에 나와있다.
EntityManager entityManager = null;
EntityTransaction txn = null;
try {
entityManager = entityManagerFactory().createEntityManager();
txn = entityManager.getTransaction();
txn.begin();
int batchSize = 25;
for (int i = 0; i < entityCount; i++) {
if (i > 0 && i % batchSize == 0) {
//flush a batch of inserts and release memory
entityManager.flush();
entityManager.clear();
}
Person Person = new Person(String.format("Person %d", i));
entityManager.persist(Person);
}
txn.commit();
} catch (RuntimeException e) {
if (txn != null && txn.isActive()) txn.rollback();
throw e;
} finally {
if (entityManager != null) {
entityManager.close();
}
}
하이버네이트 공식문서 가이드에 따르면 batchSize
값을 기준으로 flush()
, clear()
를 이용해서 영속성 컨텍스트 초기화 작업을 진행하고 있다.
batchSize에 대한 제한이 없다면 영속성 컨텍스트에 모든 엔티티가 올라가기 때문에 OutOfMemoryException이 발생할 수 있고 메모리 관리 측면에도 효율적이지 않다.
아래는 하이버네이트 공식 가이드문서 나온 부분이다.
12.2. Session batching
- Hibernate caches all the newly inserted Person instances in the session-level cache, so, when the transaction ends, 100 000 entities are managed by the persistence context. If the maximum memory allocated to the JVM is rather low, this example could fail with an OutOfMemoryException. The Java 1.8 JVM allocated either 1/4 of available RAM or 1Gb, which can easily accommodate 100 000 objects on the heap.
- long-running transactions can deplete a connection pool so other transactions don’t get a chance to proceed.
- JDBC batching is not enabled by default, so every insert statement requires a database roundtrip. To enable JDBC batching, set the hibernate.jdbc.batch_size property to an integer between 10 and 50.
hibernate.show_sql: true옵션을 주고 로그를 확인해 보면 Batch Insert가 진행되지 않은 것처럼 보인다.
로그 상으로는 Batch Insert가 진행되지 않은 것처럼 보인다.
하지만 이렇게 구현한다고 했을때 문제되는 부분이 많다.
scale-out시 이 작업이 중복되는 것도 문제이고 속도도 그렇게 빠르지 않다.
scale-out시 영향도 없고 속도도 더 빠른 방법을 고민했다.
스프링 배치는 대량의 데이터 처리 작업을 효율적으로 수행하기 위한 프레임워크다.
그래서 기존의 스케줄링을 돌리던 부분을 따로 스프링 배치 프로젝트로 만들었다.
@Configuration
public class MemberUpsertJobConfig {
private final RedisTemplate<String, String> redisTemplate;
private final MemberRepository memberRepository;
public MemberUpsertJobConfig(final RedisTemplate<String, String> redisTemplate,
final MemberRepository memberRepository) {
this.redisTemplate = redisTemplate;
this.memberRepository = memberRepository;
}
@Bean
public ItemStreamReader<TypedTuple<String>> reader() {
return new RedisCursorItemReader("clicks", redisTemplate);
}
@Bean
public ItemProcessor<TypedTuple<String>, Member> processor() {
return tuple -> {
String nickname = tuple.getValue();
Long clickCount = tuple.getScore()
.longValue();
return new Member(nickname, clickCount);
};
}
@Bean
public ItemWriter<Member> writer() {
return new MysqlItemWriter(memberRepository);
}
@Bean
public Step syncRedisToMySqlStep(final ItemReader<TypedTuple<String>> reader,
final ItemWriter<Member> writer,
final JobRepository jobRepository,
final PlatㅋformTransactionManager transactionManager) {
return new StepBuilder("syncRedisToMysqlStep", jobRepository)
.<TypedTuple<String>, Member>chunk(1000, transactionManager)
.reader(reader)
.processor(processor())
.writer(writer)
.build();
}
@Bean
public Job syncRedisToMysqlJob(final Step syncRedisToMysqlStep, final JobRepository jobRepository) {
return new JobBuilder("syncRedisToMysqlJob", jobRepository)
.incrementer(new RunIdIncrementer())
.flow(syncRedisToMysqlStep)
.end()
.build();
}
}
위에 내용은 1번에서 설명했던 코드를 스프링배치에 맞게 작성한 코드이다.
Batch Flow를 살펴보면
이렇게 했을 경우 1번과 비교했을 때 속도차이는 어마어마하다.
아래는 1번과 2번을 각각 데이터를 늘려가면서 테스트한 결과이다.
rows | 스케줄링 | 스프링 배치 |
---|---|---|
10,000 | 7.3초 | 1.2 초 |
50,000 | 23.58초 | 2.33초 |
100,000 | 57.78초 | 3.76초 |
500,000 | 4분 19초 | 14.98초 |
1,000,000 | 9분 17초 | 29.58초 |
5,000,000 | 46분 32초 | 6분 24초 |
속도가 빨라졌다고 모든 문제가 해결된 것일까?
여전히 문제는 존재했다. 현재 업데이트 하는 방식은 클릭 카운트가 변하지 않은 데이터도 가져와서 처리를 한다.
만약 업데이트해야할 데이터가 많다면 어떨까? 약 500만명이라고 6분이면 빠르다고 생각할 수 있다.
그렇다면 1000만명 혹은 5000만명이라면 전체 데이터를 업데이트 하기 부담이 될 수 있다.
현재 서비스에서도 실질적으로 하루동안 업데이트 되는 유저들은 10% 미만이다.
1000만명의 사용자가 있다고하면 100만명의 데이터만 업데이트 하면되는데 1000만명을 계속 업데이트 하는 것은 비효율적이다.
그렇다면 어떤 방법이 있을까?
내용이 많아서 2편으로 나누었다. 2편에서 계속 보기
Spring Batch 파티셔닝 적용후 JVM이 종료되지 않는 문제 해결 (0) | 2024.05.03 |
---|---|
Redis의 클릭 카운트 MySQL로 데이터 동기화 (2) (0) | 2023.12.22 |
Docker와 Kubernetes를 이용한 GKE 환경에서의 CI/CD 구현 (0) | 2023.12.05 |
Jib을 이용한 CD 최적화: Layer 캐싱 활용 (0) | 2023.11.30 |
github에서 README.md이미지 업데이트 문제: Camo와 캐싱 이슈 해결하기 (0) | 2023.10.20 |