APIの同時実行を防ぐ

課金処理や外部連携など、APIが同時に実行されては困るような時は何らかの方法でロックを取る必要がある。

データベースのトランザクションでSELECT ... FOR UPDATE NOWAITを行う方法があるが、いくつか問題がある。

  • 業務ロジック的に本来トランザクションをかけたかった範囲を超えてトランザクションをかける必要が出る可能性がある
  • 一ユースケース内にトランザクションが複数あったら、それらをまとめてロックしたい場合にはトランザクションをネストせざるを得ない

データベースのトランザクションは業務ロジック的な整合性担保のために使い、APIの同時実行を防ぐような一ユースケースの範囲を超えるものについては別の仕組みを使いたい。

Redisでロックする

RedisのNXオプションとEXオプションで時間制限付きのロックをすることによって解決する。

NXオプション + EXオプション

  • NXオプション: SETコマンドでNXオプションを付けると、キーがまだ存在しない場合にのみキーを設定できる
  • EXオプション: SETコマンドでEXオプションを付けると、指定された有効期限を秒単位で設定できる ふたつのオプションを同時に使用すると、「〜秒間有効なロック」を取得できることになる。

関連: Redisのロックを使って、JUnitが並列実行されても相互に影響が出ないようにする

Spring Bootでの実装

環境

  • Kotlin 1.6
  • Spring Boot 2.6.2

build.gradle.kts抜粋

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-redis")

実装

RedisLockというクラスを作成する。

lockメソッドとunlockメソッドを用意して、ロックをかけたい処理を挟めばいい(ただし実際にはunlockし忘れ等を防ぐためにプラスアルファの工夫をした)。

@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

@RestController
class Endpoint(
    private val sampleService: SampleService,
    private val redisLock: RedisLock
) {
    @GetMapping(path = ["/"], produces = [MediaType.APPLICATION_JSON_VALUE])
    fun endpoint(): String = redisLock.withLock("Lock::SampleService::execute", 30) { sampleService.execute() }
}

@Service
class SampleService {
    fun execute(): String {
        Thread.sleep(1000)
        return """{ "executedAt": ${System.currentTimeMillis()} }"""
    }
}

@Component
class RedisLock(private val lettuceConnectionFactory: LettuceConnectionFactory) {
    fun <R> withLock(lockName: String, ttl: Long, process: () -> R): R {
        if (!lock(lockName, ttl)) throw RuntimeException("cannot get lock. lockName=$lockName")
        try {
            return process()
        } finally {
            unlock(lockName)
        }
    }

    private fun lock(lockName: String, ttl: Long): Boolean =
        lettuceConnectionFactory.connection
            .set(lockName.toByteArray(), "dummy".toByteArray(), Expiration.seconds(ttl), SET_IF_ABSENT)!!

    private fun unlock(lockName: String): Long = lettuceConnectionFactory.connection.del(lockName.toByteArray())!!
}

実行

APIを同時に3つ実行してみると、2つはRuntimeExceptionが投げられて500エラーとなった。

$ (for i in {1..3}; do curl -w"\n" localhost:8080/ & done;wait)
{"timestamp":"2021-12-24T05:52:14.682+00:00","status":500,"error":"Internal Server Error","path":"/"}
{"timestamp":"2021-12-24T05:52:14.682+00:00","status":500,"error":"Internal Server Error","path":"/"}
{ "executedAt": 1640325135671 }

Spring Bootのエラーログは以下の通り。

2021-12-24 14:52:14.677 ERROR 42477 --- [nio-8080-exec-6] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: cannot get lock. lockName=Lock::SampleService::execute] with root cause

java.lang.RuntimeException: cannot get lock. lockName=Lock::SampleService::execute
	at com.example.ktlock.RedisLock.withLock(Application.kt:41) ~[main/:na]
	at com.example.ktlock.Endpoint.endpoint(Application.kt:27) ~[main/:na]