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]