배경
- 파이프라인을 실행 시키고 해당 파이프라인의 Task들의 결과를 업데이트 하는 API를 개발하는 도중에 동시성 문제가 발생했고 이를 해결하면서 얻은 지식들을 정리하고자 합니다.
- Tekton을 실행시킨 후 Callback 함수를 지정해주면 Pipelinerun, TaskRun의 상태변화에 따른 CloudEvent에 대한 정보를 Json 형태로 얻을 수 있습니다. (https://tekton.dev/docs/pipelines/events/)
- CloudEvent 데이터를 파싱한 후 TaskRunId, Status, Message를 얻은 후 해당 TaskRun의 상태를 업데이트 시켜줬는데 update API가 동시에 호출될 경우 손실되는 정보가 발생하는 문제가 발생했습니다.
- 트러블 슈팅을 진행하면서 이러한 문제점은 여러가지 트랜잭션이 동시에 실행될 때 발생하는 갱신 손실 문제 며, 낙관적 락 또는 비관적 락을 통해 이를 해결할 수 있다는 것을 알게 되었습니다.
코드
로직
- “Created” status를 지니며 startTime, endTime, duration 값이 null인 Task가 생성
- startTime이 비어있으면 startTime 업데이트
- “Running” 상태로 update 시키면 상태만 update
- “Succeeded” 상태로 update 시키면 endTime, duration 업데이트
- “Started”, “Running”, “Succeeded” 상태로 업데이트 API 3개를 순서대로 호출 시킨 후 의도적으로 갱신 손실 문제를 발생시키기 위해 sleepTime 변수를 넣어 thread.sleep 함수로 지정한 시간동안 스레드를 정지
DTO
@Getter
public static class CreateUpdate{
private String status;
}
Service
public TaskEntity findTask(Long id) {
Optional<TaskEntity> findTask = taskRepository.findById(id);
return findTask.orElse(null);
}
@Transactional
public TaskEntity updateTask(Long id, TaskDto.CreateUpdate createUpdate, int sleepTime){
TaskEntity taskEntity = findTask(id);
threadSleep(sleepTime);
taskEntity.updateStatus(createUpdate.getStatus());
taskEntity.updateTime(createUpdate.getStatus());
return taskEntity;
}
public void threadSleep(int sleepTime){
try {
Thread.sleep(sleepTime);
} catch (Exception e){
log.info(e.getMessage());
}
}
Repository
public interface TaskRepository extends JpaRepository<TaskEntity, Long>
Domain
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class TaskEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String status;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime startTime;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime endTime;
private Long duration;
@Builder
public TaskEntity(String status){
this.status = status;
}
public void updateStatus(String status){
this.status = status;
}
public void updateTime(String status) {
if (this.startTime == null){
this.startTime = LocalDateTime.now().withNano(0);
}
if (status.equals("Succeeded")){
this.endTime = LocalDateTime.now().withNano(0);
this.duration = Duration.between(this.startTime,this.endTime).getSeconds();
}
}
}
Test
@Test
@DisplayName("Lock 적용하기 전")
void test1() throws Exception {
TaskEntity newTask = TaskEntity.builder()
.status("Created")
.build();
taskService.createTask(newTask);
final ExecutorService executor = Executors.newFixedThreadPool(3);
executor.execute(()->taskService.updateTask(newTask.getId(), TaskDto.CreateUpdate.builder().status("Started").build(), 2000));
Thread.sleep(500);
executor.execute(()->taskService.updateTask(newTask.getId(), TaskDto.CreateUpdate.builder().status("Running").build(), 1000));
Thread.sleep(500);
executor.execute(()->taskService.updateTask(newTask.getId(), TaskDto.CreateUpdate.builder().status("Succeeded").build(), 100));
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
TaskEntity findTask = taskService.findTask(newTask.getId());
assertAll(
()-> assertEquals("Started", findTask.getStatus()),
()-> assertNotEquals(null,findTask.getStartTime()),
()-> assertEquals(null,findTask.getEndTime()),
()-> assertEquals(null,findTask.getDuration())
);
}
- “Created” status를 지니며 startTime, endTime, duration 값이 null인 Task가 존재
- “Started” 상태로 업데이트 시키는 트랜잭션 실행 - 2초 소요
- 0.5초 후 “Running” 상태로 업데이트 시키는 트랜잭션 실행 - 1초 소요
- 0.5초 후 “Succeeded” 상태로 업데이트 시키는 트랜잭션 실행 - 0.1초 소요
- “Started”, “Running”, “Succeeded” 상태로 업데이트 API 3개를 순서대로 호출했지만 각 API 처리 속도에 따라서 최종적으로 업데이트 되는 정보가 달라집니다.
- 이 경우 “Started” 상태로 업데이트 시키는 함수가 가장 먼저 호출됬지만 트랜잭션이 가장 늦게 끝났기 때문에 최종적으로 “Started” 상태, 시간은 전부 null로 업데이트
- status만 변경했는데 startTime, endTime, duration도 전부 null로 업데이트 된 이유는 JPA의 Dirty Checking 때문입니다.
- JPA는 데이터를 조회해서 영속성 컨텍스트 안에 저장하고 트랜잭션이 끝났을 때 초기 상태와 최종 상태를 비교해서 update 쿼리를 날린다 - Dirty Checking
- Dirty Checking로 생성되는 update 쿼리는 기본적으로 모든 필드를 업데이트
도식화
결론
- 하나의 엔티티에 업데이트를 하는 트랜잭션이 동시에 2개 이상 진행 될 때 먼저 트랜잭션이 끝난 작업의 변경 내용이 DB에 반영되지 않는 문제점이 발생
- 가장 마지막에 시작 된 트랜잭션의 작업이 보장 되는 것이 아니라 트랜잭션의 끝나는 시점에 따라 DB 내용이 달라집니다.
- 최종적으로 이미 작업이 끝난 Task인 경우라도 상태가 Running 또는 Started로 남아 있는 상황이 발생