kyucumber
전체 글 보기

spring data redis에서 atomic operation 보장을 위해 Lua script 활용하기

팀 내에서 서비스 운영을 위해 Redis Clutser를 사용하고 있으며 내부 코드에 아래와 같이 PartialUpdate를 사용해 구현된 부분이 존재합니다.

redisKVTemplate.update( PartialUpdate(id, Data::class.java) .set("timeout", timeout) .set("accessedAt", accessedAt) )

내부적으로 Data의 만료 여부를 체크하고 만료된 데이터를 삭제하는 로직이 존재하는데 멀티 인스턴스에서 여러 operation이 들어오다 보니 삭제된 이후에 Data에 대한 PartialUpdate가 수행되는 경우가 빈번하게 발생했습니다.

org.springframework.data.mapping.model.MappingInstantiationException: Failed to instantiate data.Data using constructor fun <init>(kotlin.String, kotlin.String, kotlin.String, kotlin.String, kotlin.String, java.time.Instant, java.time.Instant, kotlin.Long):

삭제된 이후 PartialUpdate를 수행하면 timeout, accessedAt 값 이외의 값들이 존재하지 않게 되며 해당 값들은 Kotlin에서 non null 타입으로 정의되어 있어 위와 같은 예외가 발생하게 됩니다.

서비스에 큰 이슈는 없는 상황이라 방치하고 있었지만 atomic operation을 보장해 위와 같은 케이스가 발생하지 않도록 수정이 필요했습니다.

Redis와 Transaction

Cluster 모드가 아닌 경우라면 MULTI, EXEC, DISCARD, WATCH 등의 키워드를 이용해 spring-data-redis 내에서 트랜잭션을 보장할 수 있습니다.

하지만 클러스터 모드를 사용하는 경우 spring-data-redis 내부에서는 트랜잭션 보장을 지원하지 않습니다.

따라서 spring-data-redis의 트랜잭션 서포트를 기대할 수 없는 상황이며 atomic operation 보장을 위해서 다른 방법을 사용해야 합니다.

Redis는 서버 내부의 Lua 인터프리터를 이용해 Lua script로 작성된 코드를 해석하고 실행할 수 있습니다. Lua script를 활용해 여러 명령어를 조합할 수 있으며, 스크립트 자체가 하나의 명령어와 같이 동작하기 때문에 atomic operation을 보장할 수 있습니다.

Lua script를 사용하더라도 여러 샤드에 있는 키를 조작하거나 하는 행동은 불가능하므로 스크립트에 관련된 키가 단일 샤드에서 실행되도록 보장해야 합니다.

spring data redis와 Lua script

spring-data-redis에서는 Lua script의 사용을 지원합니다.

atomic을 보장하고 싶은 operation의 내용은 아래와 같습니다.

  • Hash의 특정 field가 존재하는 경우에만 PartialUpdate를 수행한다.
  • Hash의 특정 field가 존재하지 않는 경우 해당 키의 데이터를 제거한다.

해당 operation의 수행을 위해 아래와 같은 Lua script 작성 및 설정을 통해 atomic operation을 보장할 수 있습니다.

resources/scripts/update_data_if_exist.lua

-- update_data_if_exist.lua if redis.call('HEXISTS', KEYS[1], 'username')==1 then redis.call('HSET', KEYS[1], 'timeout', ARGV[1]) redis.call('HSET', KEYS[1], 'accessedAt', ARGV[2]) else redis.call('DEL', KEYS[1]) end

RedisLuaScriptConfig

@Configuration class RedisLuaScriptConfig { @Bean fun updateIfExistScript(): RedisScript<Unit> = RedisScript.of(ResourceScriptSource(ClassPathResource("scripts/update_data_if_exist.lua")).scriptAsString) }

DataRepository

interface DataRepository : CrudRepository<Data, String>, DataCustomRepository interface DataCustomRepository { fun updateTimeoutAndAccessedAt(data: Data, accessedAt: Instant = Instant.now()) } @Repository class DataCustomRepositoryImpl( private val updateIfExistScript: RedisScript<Unit>, private val stringRedisTemplate: RedisTemplate<String, String> ) : DataCustomRepository { override fun updateTimeoutAndAccessedAt(data: Data, accessedAt: Instant) { stringRedisTemplate.execute( updateIfExistScript, listOf("data:${session.id}"), data.timeout.toString(), accessedAt.toString() ) } }

Reference

개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.

오류가 있다면 댓글을 남겨주세요.