주요글: 도커 시작하기
반응형

다음과 같은 요구사항을 처리할 일이 생겨 분산 락이 필요했다.

  • 어플리케이션에 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)
)

 

분산 락 동작

분산 락이 필요한 쓰레드(프로세스)는 다음과 같은 절차에 따라 락을 구한다.

  1. 트랜잭션을 시작한다.
  2. select for update 쿼리를 이용해서 구하고자 하는 행을 점유한다.
  3. owner가 다른데 아직 expiry가 지나지 않았으면 락 점유에 실패한다.
  4. owner가 다른데 expiry가 지났으면 락 owner를 나로 바꾸고 expiry를 점유할 시간에 맞게 변경한다.
  5. owner가 나와 같으면 expiry를 점유할 시간에 맞게 변경한다.
  6. 트랜잭션을 커밋한다.
  7. 락 점유에 성공하면(4, 5) 원하는 기능을 실행한다.
  8. 락 점유에 실패하면(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()에서 실행하는 코드의 실행 시간이 락 지속 시간보다 길면 안 됨
  • 명시적으로 락을 해제하는 기능 없음

 

+ Recent posts