반응형
다음과 같은 요구사항을 처리할 일이 생겨 분산 락이 필요했다.
- 어플리케이션에 1분 간격으로 실행하는 작업이 있음
- 이 작업은 한 쓰레드에서만 실행해야 함
- 여러 장비에서 어플리케이션을 실행할 수 있음
Zookeeper나 Consul 같은 서비스를 사용하고 있지 않아 DB를 이용해서 단순하게 분산 락을 구현했다.
락 정보 저장 테이블
락 정보를 담을 DB 테이블 구조는 다음과 같이 단순하다.
name은 락 이름, owner는 락 소유자를 구분하기 위한 값, expiry는 락 소유 만료 시간이다.
다음은 MySQL을 위한 테이블 생성 쿼리이다.
CREATE TABLE dist_lock
(
name varchar(100) NOT NULL COMMENT '락 이름',
owner varchar(100) NOT NULL COMMENT '락 소유자',
expiry datetime NOT NULL COMMENT '락 만료 시간',
primary key (name)
)
분산 락 동작
분산 락이 필요한 쓰레드(프로세스)는 다음과 같은 절차에 따라 락을 구한다.
- 트랜잭션을 시작한다.
- select for update 쿼리를 이용해서 구하고자 하는 행을 점유한다.
- owner가 다른데 아직 expiry가 지나지 않았으면 락 점유에 실패한다.
- owner가 다른데 expiry가 지났으면 락 owner를 나로 바꾸고 expiry를 점유할 시간에 맞게 변경한다.
- owner가 나와 같으면 expiry를 점유할 시간에 맞게 변경한다.
- 트랜잭션을 커밋한다.
- 락 점유에 성공하면(4, 5) 원하는 기능을 실행한다.
- 락 점유에 실패하면(3) 원하는 기능을 실행하지 않는다.
DB 락 구현
실제 락을 구현할 차례다. 원하는 코드 형태는 대략 다음과 같다.
lock.runInLock(락이름, 락지속시간, () -> {
// 락을 구하면 수행할 작업
});
다음은 DB 테이블을 이용한 분산 락 구현 코드이다.
import javax.sql.DataSource;
import java.sql.*;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
public class DbLock {
private final String ownerId;
private final DataSource dataSource;
public DbLock(DataSource dataSource) {
this.dataSource = dataSource;
this.ownerId = UUID.randomUUID().toString();
}
public void runInLock(String name, Duration duration, Runnable runnable) {
if (getLock(name, duration)) {
runnable.run();
}
}
private boolean getLock(String name, Duration duration) {
Connection conn = null;
boolean owned;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
OwnerInfo ownerInfo = getLockOwner(conn, name);
if (ownerInfo == null) {
insertLockOwner(conn, name, duration);
owned = true;
} else if (ownerInfo.owner.equals(this.ownerId)) {
updateLockOwner(conn, name, duration);
owned = true;
} else if (ownerInfo.expiry.isBefore(LocalDateTime.now())) {
updateLockOwner(conn, name, duration);
owned = true;
} else {
owned = false;
}
conn.commit();
} catch (Exception e) {
owned = false;
if (conn != null) {
try {
conn.rollback();
} catch (SQLException ex) {
}
}
} finally {
if (conn != null) {
try {
conn.setAutoCommit(false);
} catch(SQLException ex) {}
try {
conn.close();
} catch (SQLException e) {
}
}
}
return owned;
}
private OwnerInfo getLockOwner(Connection conn, String name) throws SQLException {
try (PreparedStatement pstmt = conn.prepareStatement(
"select * from dist_lock where name = ? for update")) {
pstmt.setString(1, name);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return new OwnerInfo(
rs.getString("owner"),
rs.getTimestamp("expiry").toLocalDateTime());
}
}
}
return null;
}
private void insertLockOwner(Connection conn, String name, Duration duration)
throws SQLException {
try(PreparedStatement pstmt = conn.prepareStatement(
"insert into dist_lock values (?, ?, ?)")) {
pstmt.setString(1, name);
pstmt.setString(2, ownerId);
pstmt.setTimestamp(3,
Timestamp.valueOf(
LocalDateTime.now().plusSeconds(duration.getSeconds()))
);
pstmt.executeUpdate();
}
}
private void updateLockOwner(Connection conn, String name, Duration duration)
throws SQLException {
try(PreparedStatement pstmt = conn.prepareStatement(
"update dist_lock set owner = ?, expiry = ? where name = ?")) {
pstmt.setString(1, ownerId);
pstmt.setTimestamp(2,
Timestamp.valueOf(
LocalDateTime.now().plusSeconds(duration.getSeconds()))
);
pstmt.setString(3, name);
pstmt.executeUpdate();
}
}
}
https://github.com/madvirus/db-lock-sample 에서 코드를 확인할 수 있다.
추가 고려사항
현재 구현은 당장의 요구를 충족하는데 필요한 만큼만 기능을 구현한 것으로 다음을 고려한 개선이 필요하다.
- runInLock()에서 실행하는 코드의 실행 시간이 락 지속 시간보다 길면 안 됨
- 명시적으로 락을 해제하는 기능 없음