DB에서 Lock은 동시에 여러 사용자나 프로세스가 동일한 데이터를 변경하는 것을 방지하기 위한 메커니즘입니다.
Lock이 설정된 데이터는 해당 Lock이 해제될 때까지 다른 사용자들은 접근하거나 수정할 수 없습니다.
이를 통해 데이터의 일관성과 무결성을 유지할 수 있습니다.
record lock은 인덱스 레코드에 락을 걸어서 수정, 삽입, 삭제등의 다른 트랜잭션을 막습니다.
update feed_like_count set like_count = 2 where feed_id = 1
위와 같이 feed_id 인덱스가 1인 레코드 전체에 락을 걸어버립니다.
만약 feed_id 컬럼에 인덱스가 존재하지 않는다면 테이블 전체에 락을 걸어버릴 수도 있기 때문에 주의하셔야 합니다.
update feed set is_delete = true where user_id = 1 and is_hide = true
레코드 락은 조회 한 인덱스를 기준으로 락을 거는것이기 때문에 주의가 필요합니다.
위 쿼리문을 실행하게 되었을 때 user_id에 인덱스가 존재하고 is_hide에는 인덱스가 존재하지 않는다면 user_id가 1인 인덱스에 락이 걸리게 됩니다.
만약 user_id와 is_hide 두 컬럼을 포함한 인덱스가 존재한다면 락의 범위는 user_id가 1, is_hide가 true인 인덱스에 락이 걸립니다. 위 상황보다 락의 범위가 좁아지는 것을 볼 수 있습니다.
흔히 읽기락, 쉐어드 락이라고 불리는 락입니다.
select * from feed_like_count where feed_id = 1 for share;
위 쿼리를 실행하면 feed_id가 1인 레코드에 쉐어드 락을 겁니다.
쉐어드 락은 다른 트랜잭션에서 읽기 요청을 보냈을 때 락을 걸지 않습니다.
트랜잭션1
SET autocommit = 0; start transaction; select * from feed_like_count where feed_id = 1 for share;
트랜잭션2
select * from feed_like_count where feed_id = 1 for share;
위 두 요청은 병렬 처리
트랜잭션1
SET autocommit = 0; start transaction; select * from feed_like_count where feed_id = 1 for share;
트랜잭션2
update feed_like_count set like_count = 2 where feed_id = 1;
트랜잭션2 요청은 락 점유를 위해 대기
흔히 쓰기락, 베타락이라고 불리는 락입니다.
select * from feed_like_count where feed_id = 1 for update;
위 쿼리를 실행하면 feed_id가 1인 레코드에 베타락을 겁니다.
베타락은 다른 트랜잭션에서 읽기 요청을 보냈을 때 락을 걸지 않습니다.
트랜잭션1
SET autocommit = 0; start transaction; select * from feed_like_count where feed_id = 1 for update;
트랜잭션2
select * from feed_like_count where feed_id = 1 for update;
트랜잭션2 요청은 락 점유를 위해 대기
트랜잭션1
SET autocommit = 0; start transaction; select * from feed_like_count where feed_id = 1 for update;
트랜잭션2
update feed_like_count set like_count = 2 where feed_id = 1;
트랜잭션2 요청은 락 점유를 위해 대기
트랜잭션 격리레벨 레벨 4인 serializable 입니다.
트랜잭션 격리레벨을 serializable 로 설정하게 되면 일반적인 select 쿼리도 쓰기락(for update)이 적용됩니다.
성능이 매우 안좋아지고 대신 데이터 적합성은 보장이 됩니다.
갭 락은 레코락과 비슷하게 레코드(인덱스) 위 아래에 락을 겁니다.
예시를 들겠습니다.
트랜잭션1
SET autocommit = 0; start transaction; update feed_like_count set like_count = 2 where feed_id = 1;
트랜잭션2
insert into feed_like_count (id, like_count, feed_id) value (2, 0, 2);
트랜잭션2 요청은 락 점유를 위해 대기
update 쿼리를 보면 feed_id가 1 인 인덱스에 락을 걸게 됩니다.
feed_id가 2인 값이 추가 될 때 데이터의 적합성을 위해 InnoDB 엔진에서 gap lock 이라는 락을 지원해줍니다.
트랜잭션1
SET autocommit = 0;
start transaction;
select * from feed where content like 'a%' for update;
트랜잭션2
insert into feed (id, content) value (2, 'b');
a로 시작하는 모든 데이터들은 레코드락이 걸린다!
트랜잭션2는 b 를 insert 할 때 락에 걸리게 된다.(insert 하는 데이터가 다음행에 들어갈 때)
record lock과 gap lock을 모두 합친 잠금입니다.
일반적으로 gap lock은 단독으로 실행되지 않고 nextkey lock 이 실행될 때 실행됩니다.
트랜잭션1 - nextkey lock이 걸림
SET autocommit = 0; start transaction; update feed_like_count set like_count = 2 where feed_id = 1; or SET autocommit = 0; start transaction; select * from feed_like_count where feed_id = 1 for share;
트랜잭션2 - gap lock에 걸림
insert into feed_like_count (id, like_count, feed_id) value (2, 0, 2);
트랜잭션3 - record lock에 걸림
select * from feed_like_count where feed_id = 1 for share; select * from feed_like_count where feed_id = 1 for update;
mysql에서 pk의 값을 auto increment 로 설정해놓으면 여러행이 insert 될 때 자동으로 락이 걸립니다.
auto increment lock 은 키 값이 증가되면 자동으로 줄어들지 않습니다.
트랜잭션이 rollback이 되어 insert 쿼리가 실패하더라도 pk는 auto increment 한 값이 내부적으로 남습니다.
또한 auto increment lock은 엄청 짧은 순간만 걸렸다가 바로 해제되기 때문에 대부분 문제 되지 않습니다.
현재 테이블은 게시글 테이블과 게시글의 좋아요 테이블을 분리해 뒀습니다.
이유는 feed_like_count 에서 lock 이나 update 쿼리가 발생시 feed_id (인덱스) 컬럼에 레코드 락을 걸기 때문입니다.
feed 테이블 안에 feed_like_count 컬럼을 생성하고 하나의 테이블 형태로 가게 되면 feed 테이블은 좋아요 요청을 받을 때 마다 id 레코드에 락을 걸어 성능이 매우 떨어지기 때문입니다.
예를 들어 조회수 컬럼이 생겼다고 가정하겠습니다.
조회수를 update 해야하는 상황에 좋아요 연산이 락을 점유하고 있다면 조회수 증가 연산은 락을 점유하기 위해 대기해야 합니다.
이처럼 게시글과 좋아요 수를 담고 있는 테이블을 분리하면 성능 개선과 데드락을 예방할 수 있습니다.
레코드 락 최소화: 게시글 테이블과 좋아요 테이블을 분리함으로써 좋아요 기능이 레코드 락을 요구할 때 게시글에 영향을 주지 않게 됩니다. 따라서 조회수를 업데이트하는 동안 좋아요 연산이 레코드 락을 점유하는 문제가 발생하지 않습니다.
성능 향상: 게시글과 관련된 다른 작업들이 좋아요 작업에 영향을 받지 않기 때문에 테이블을 분리함으로써 서로간의 성능이 개선됩니다.
데드락 방지: 테이블을 분리하여 각각의 작은 트랜잭션 범위를 가져갈 수 있으므로 데드락 문제를 예방할 수 있습니다.
따라서 게시글 테이블과 좋아요 테이블을 분리함으로써 레코드 락과 성능에 대한 이슈를 최소화할 수 있으며 데이터베이스의 안정성과 성능을 향상시킬 수 있습니다.
지금 프로젝트에서는 좋아요를 누르면 좋아요 count + 1
과 누가 어떤 게시물에 좋아요를 눌렀는가
의 대한 처리를 해줘야합니다.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void saveLike(Long userId, Long feedId) {
User user = userRepository.findById(userId)
.orElseThrow(UserNotFoundException::new);
Feed feed = increaseLikeCount(feedId);
saveLikeInfo(user, feed);
}
public Feed increaseLikeCount(Long feedId) {
FeedLikeCount feedLikeCount = feedLikeRepository.findByFeedId(feedId)
.orElseThrow(FeedNotFoundException::new);
feedLikeCount.like();
return feedLikeCount.getFeed();
}
public void saveLikeInfo(User user, Feed feed) {
FeedLikeInfo userLike = FeedLikeInfo.of(user, feed);
feedLikeInfoRepository.save(userLike);
}
지금 트랜잭션의 범위는 조회
와 좋아요 숫자 증가
, 게시물 좋아요 정보 입력
을 한 트랜잭션 범위로 잡아놨습니다.
좋아요 숫자 증가
로직부터 베타락이 걸리기 때문에 트랜잭션의 범위를 작게 잡아주는게 좋습니다.
현재 프로젝트에서는 개발 단계이고 아직 좋아요 구간에서 병목현상이 일어나지 않았기 때문에 트랜잭션의 범위를 보다 넓게 잡았습니다.
트래픽이 늘어나고 병목 현상이 생기기 시작할 때는 게시물 좋아요 정보 입력
을 트랜잭션에서 제외를 해도 됩니다.
그리고는 예외처리를 따로 해줘야할것입니다. ex) 트랜잭션 실패시 롤백 로직 따로 작성
위와 같은 방법으로도 병목이 지속 된다면 낙관적 락(optimistic lock)으로 정책을 수정해야 할 것입니다.
하지만 위와 같은 방법들은 직접적으로 요청 1번 = DB update 1번
형식이기 때문에 미들웨어(redis)를 통해 연산을 한 뒤 연산된 값들을 스케쥴러를 통해 DB에 update 해주는 방식도 존재합니다.
※ 메인 테이블과 서브 테이블 구조에서 외래키 제약이 걸려있다면 메인 테이블에서 데이터가 변경이 될 때 서브 테이블에는 Shared Lock이 걸린다.
public interface FeedLikeRepository extends JpaRepository<FeedLikeCount, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<FeedLikeCount> findByFeedId(Long feedId);
}
만약 낙관적 락으로 변경을 하게 된다면 @Lock(LockModeType.PESSIMISTIC_WRITE) 부분을 @Lock(LockModeType.OPTIMISTIC)으로 수정해야합니다.
그리고 FeedLikeCount 엔티티에 version 필드를 추가하고 @Version을 붙혀줘야합니다.