본문 바로가기
CS/Database

NamingLock을 이용한 동시성 이슈, Deadlock query 이슈 해결

by clearinging 2021. 12. 1.
반응형

동시성 이슈 + Deadlock query 이슈 해결

이슈 발생 종류 : deadlock, race condition

  • 간단하게 Entity 연관관계를 설명하자면 Food <Like> User관계 입니다.
  • 사용자가 음식사진과 정보를 보고 좋아요를 누르는 기능입니다. (facebook, instargram과 동일한 기능)
  • 발생한 이슈는 동일한 food에 여러 사용자가 like를 누를 경우 dead lock 이슈와, like Entity에 적제된 row수와 food에 반정규화로 설정한 numberOfLikes 컬럼의 값과 불일치 이슈가 발생

비즈니스 로직

간단한 Table 구조

간단한 Entity 구조

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Food extends BaseEntity {
    @Id
    @Column(name = "food_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

   //... codes
	@Column(nullable = false)
    private long numberOfLikes; // 반정규환 한 값
    
    @Embedded
    private final Likes likes = new Likes();

    //... codes

}

 

@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Likes {

    @OneToMany(mappedBy = "food", cascade = CascadeType.ALL, orphanRemoval = true)
    private final List<Like> likes = new ArrayList<>();

    public int getSize() {
        return likes.size();
    }

    public void assignLike(Like like, Food food) {
        if (Objects.isNull(like)) {
            throw new LikeNotFoundException();
        }
        validateAlreadyLikeUser(like.getUser().getId());

        like.assignFood(food);
        if (!likes.contains(like)) {
            likes.add(like);
            food.addNumberOfLike();
        }
    }

    public void deleteLike(Long userId, Food food) {
        Like like = findLikeByUserId(userId);
        likes.remove(like);
        food.deleteNumberOfLike();
    }

    private Like findLikeByUserId(Long userId) {
        return likes.stream()
                .filter(like -> like.getUser().getId().equals(userId))
                .findAny()
                .orElseThrow(ForbiddenException::new);
    }

    private void validateAlreadyLikeUser(Long userId) {
        if (isAlreadyLike(userId)) {
            throw new InvalidOperationException("이미 like한 사용자 입니다.");
        }
    }

    private boolean isAlreadyLike(Long userId) {
        return likes.stream()
                .anyMatch(like -> like.getUser().getId().equals(userId));
    }
}
@Entity
@Getter
@Table(name = "likes", uniqueConstraints = {
        @UniqueConstraint(
                name = "unique_key_food_user",
                columnNames = {"user_id", "food_id"}
        )
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Like extends BaseEntity {
    @Id
    @Column(name = "like_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "food_id")
    private Food food;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    public Like(User user) {
        this.user = user;
    }

    public static Like of(User user) {
        return new Like(user);
    }

    public void assignFood(Food food) {
        if (Objects.isNull(food)) {
            throw new FoodNotFoundException();
        }

        if (Objects.nonNull(this.food)) {
            throw new InvalidOperationException("한번 할당된 게시글은 변경할 수 없습니다.");
        }
        this.food = food;
        food.getLikes().getLikes().add(this);
        food.addNumberOfLike();
    }
}
  • user에서 like를 조회하는 경우가 거의 없어서 User에는 연관관계를 설정하지 않았습니다.
  • 그렇기 때문에 여기서 User Entity는 생략 합니다.

Like code

  • Entity 비즈니스 로직
public void assignLike(Like like, Food food) {
    if (Objects.isNull(like)) {
        throw new LikeNotFoundException();
    }
    validateAlreadyLikeUser(like.getUser().getId());

    like.assignFood(food); // like Entity 에 food 추가
    if (!likes.contains(like)) {
        likes.add(like); // Like Entity List에 like Entity 추가
        food.addNumberOfLike();
    }
}
  • service 로직
@Transactional
public Long saveLike(User user, Long foodId, String categoryName) {
    Food findFood = findCategoryIncludeFood(foodId, categoryName);

    Like like = Like.of(user);
    findFood.assignLike(like);
    likeRepository.flush();

    return like.getId();
}

실제 race condition 테스트

like 저장 결과
food 저장 결과

  • 멀티쓰레들를 돌려 약 20명의 사용자가 food_id가 1인 Food row에 동시에 like를 누를 경우를 테스트 했습니다.
  • 20명중 15명이 deadlock으로 요청 취소라는 결과를 받게 되었습니다.
  • numberOfLikes에는 원래 5라는 값이 들어가야 하지만, 2라는 결과가 반영되었습니다.
  • -> 결론: deadlock과 race condition 이슈가 발생

Deadlock

원인

  • 외래 키로 인한 deadlock
START TRANSACTION; 

select * from food as f inner join category as c on f.category_id = c.category_id where f.food_id = 1;

select * from likes left outer join user on likes.user_id = user.user_id where likes.food_id=1;

insert into likes (create_date, last_modified_date, food_id, user_id) values(now(), now(), 1, 3); -- food id가 1인 row를 shared lock

update food set last_modified_date=now(), category_id=2, food_status='SHARED', food_title='title1 data', number_of_likes=3, price=1100, review_msg='review msg', writer_id=1, writer_nickname='owaowa'
where food_id=1; -- food update -> shared lock이 있을경우 대기, dead lock 발생 시점

COMMIT;
  • food table과 like table은 OneToMany 관계
  • likes 가 insert 가 발생하게되면 외래키로 인해서 정합성을 검증해야하게 됩니다 그러므로 food 도 같이 공유 lock이 발생
  • 이때 다른 transaction이 food table의 s lock이 걸린 row를 update할 경우 대기 상태에 빠지게 됩니다.
  • 하지만 like 추가 로직에서는 food를 둘다 update 를 하게 되므로 deadlock이 발생하게됩니다.

flow

해결 방안

  • 연관관계만 설정하고 외래키 제약 조건을 제거 합니다.
  • likes table에 insert query가 발생하면 food에 s lock 이 발생하지 않기 때문에 이슈를 해결할 수 있습니다.

Race Condition

이슈 원인

  • jpa 에서 atomic update query를 사용할 수는 없음
  • mysql default isolation 제약 조건인 repeatable read를 사용하기 때문에 각 transaction 마다 version이 존재하게 됩니다.
  • version 으로 인해서 update 쿼리로 인해 overwrite 발생 이슈가 존재

해결 방법

  1. select ... for update keyword 사용하기
  • 문제점: select로 조회할 경우 여러 row가 존재할 경우 문제가 없지만, 만약 row수가 0인 경우 insert 에 대해서 lock을 걸게 됩니다. -> 성능 이슈 유발
  1. Naming lock 사용하기
  • 저는 이 방식을 사용하기 로 마음을 먹었습니다
  • DB: mysql

Naming Lock

정의

  • 사용자가 특정 문자열에 Lock을 걸 수 있는 Lock을 의미

Lock 종류

  • Table level lock, Row level lock
  • 이 번장에서는 Naming level lock에 대해 설명이기 때문에 이 2가지 lock은 다음에 다루 도록 하겠습니다.

사용 이유

  • 접속한 사용자들의 동시성을 제어 하기 위해서 사용
  • RDB인 경우 row수가 많아지게되면 여러 table로 물리적으로 나누는 파티셔닝을 진행하기도 합니다.
    • 파티셔닝: 단일 테이블 처럼 보이지만 실제 물리적으로는 여러 테이블에 나눠진 상태를 의미
    • 파티셔닝은 파티셔닝 키안에 primary key를 넣어서 유니크한 row를 유지
    • unique 제약 조건은 추가할 수 없다는 제약 사항이 존재
    • unique key를 사용하지 못 하기 때문에 Naming lock 을 사용해 아래 함수를 통해 1명의 유일한 사용자가 접속하게 해서 필요한 비즈니스 로직을 실행하게 하기 위해서 사용
  • 프로젝트에 접목 이유
    • like와 bookmark 기능이 있다. 그리고 like와 bookmark 를 user와 food table의 중간 테이블로 사용한다.
    • order by의 비즈니스 로직과 select query 비즈니스 로직에서 많은 테이블을 join하므로 slow 쿼리가 우려 되었기 때문에 한 테이블의 join을 줄이고자 반 정규화 진행
    • food에 like수와 bookmark수를 저장 -> 반 정규화된 like bookmark column이 나중에 race condition으로 인해 likes table의 row 수와 food.like의 수가 다를 것이 우려가 되었습니다.
    • 이걸 해결하기 위해서 Naming level lock 사용하기로 계획 수립

Mysql NamingLock lock을 사용하는 이유

  • 실무에서는 Mysql, Redis를 이용한 방식이 있다는 것을 알게 되었습니다.
  • redis 는 event loop 기반 싱글 스레드를 사용하고 in memory db라는 점에서 mysql 보다 빠른 속도를 발생합니다, 하지만 redis 라는 새로운 리소스를 사용해야하는 이슈가 발생
  • 이유 요약: 기존 자원인 mysql을 응용하고 싶어서(토이 프로젝트에서 돈이 없어서 redis 를 못 붙이는건 아쉽습니다....)

Naming Lock method 종류

  1. GET_LOCK(str, timeout)
  • lock을 획득하는 함수
  • str: 해당 lock을 획득하기 위한 문자열, 동일한 str에 대해서 lock을 설정합니다.
  • timeout: lock을 획득하는 최대 기간
  • 반환 값
    • lock 획득 성공 : 1
    • lock 획득 실패 : 0
    • 에러: NULL 반환
  1. IS_FREE_LOCK(str)
  • 문자열 str이 사용할 수 있는지 check
  1. IS_USED_LOCK(str)
  • 문자열 str이 사용하고 있는지 check
  1. RELEASE_LOCK(str)
  • str에 걸려 있는 lock 해제

User lock 사용하면서 후기

  • userlock을 사용할 경우 동일한 food에 100명이 동시에 좋아요를 누를 경우 약 10배 가까이 느려진건 사실이다. 하지만 데이터 race condition으로 인한 이슈가 사라지기 때문에 이득일 수 도 있다고 생각이 듭니다.
반응형

'CS > Database' 카테고리의 다른 글

RDB Index  (0) 2021.12.09
RDB Join 방식  (0) 2021.12.09
DB Isolation  (0) 2021.10.30
Mysql Replication  (3) 2021.10.06
DB 스키마  (0) 2021.06.05