Anıl Şenocak

Beyond Caching: Redis as a Search Engine
In this article, we'll explore how to build a high-performance search system using RediSearch with Spring Boot and Kotlin. We'll also compare two popular Redis Java clients: Jedis and Redisson, examining their strengths and weaknesses in the context of search operations.
RediSearch is a Redis module that adds full-text search capabilities to Redis. It provides:
Unlike traditional search engines like Elasticsearch, RediSearch operates entirely in memory, making it incredibly fast for real-time search operations.
Our project demonstrates RediSearch capabilities using Istanbul's traffic density data. The application provides geo-spatial search functionality, allowing users to:
The large dataset is retrieved from a CSV file containing traffic density information and downloaded from İstanbul Büyükşehir Belediyesi, which is then inserted and indexed in Redis for fast search operations.
Need to configure your Redis instance with the RediSearch module. If you’re using Docker, you can run a Redis Stack server that includes RediSearch:
version: '3.8'
services:
redis-stack-single:
image: redis/redis-stack-server:7.2.0-v18
ports:
\- "6382:6379"
healthcheck:
test: \[ "CMD", "redis-cli", "--raw", "incr", "ping" \]
Our traffic density model captures real-time traffic information:
@RedisHash(value = "traffic\_density")
data class TrafficDensity(
@Id val id: UUID? = UUID.randomUUID(),
val dateTime: String,
val latitude: String,
val longitude: String,
val geohash: String,
val minimumSpeed: Int,
val maximumSpeed: Int,
val averageSpeed: Int,
val numberOfVehicles: Int,
): Serializable {
var location: String? = null
}
The @RedisHash annotation tells Spring Data Redis to store this as a hash in Redis with the prefix traffic_density:
Need to add the necessary dependencies in your build.gradle.kts file:
implementation("redis.clients:jedis:5.2.0") // Jedis client for Redis
// Following dependencies are for Redisson
implementation("io.lettuce:lettuce-core:6.1.8.RELEASE")
implementation("org.redisson:redisson-spring-boot-starter:3.45.1")
implementation("org.redisson:redisson:3.45.1")
Then, redis configuration should be set to match your Redis instance. You can use either Jedis or Redisson based on your preference.
The @Indexed annotation is used to mark fields that should be indexed by Spring Data Redis. This allows for efficient querying of these fields using Redis commands. However, it does not provide the advanced search capabilities that RediSearch offers, such as full-text search, geo-spatial queries, or complex filtering.
RediSearch provides a powerful indexing engine that supports complex queries, including full-text search, geo-spatial queries, and numeric range filtering. It allows for more advanced search capabilities compared to the basic @Indexed annotation.
With Redisson, creating a RediSearch index is straightforward and type-safe:
search.createIndex(INDEX\_TRAFFIC\_DENSITY,
IndexOptions.defaults().on(IndexType.HASH).prefix(listOf("traffic\_density:")),
FieldIndex.tag("id"),
FieldIndex.text("\_class"),
FieldIndex.numeric("latitude"),
FieldIndex.numeric("longitude"),
FieldIndex.geo("location"),
FieldIndex.text("geohash"),
FieldIndex.numeric("numberOfVehicles"),
FieldIndex.numeric("minimumSpeed"),
FieldIndex.numeric("averageSpeed"),
FieldIndex.numeric("maximumSpeed"),
)
Jedis requires more verbose configuration but offers fine-grained control:
val params: FTCreateParams = FTCreateParams.createParams().addPrefix("traffic\_density:")
val sc: Schema = Schema()
.addTagField("id")
.addTextField("dateTime", 1.0)
.addTextField("latitude", 1.0)
.addTextField("longitude", 1.0)
.addGeoField("location")
.addTextField("geohash", 1.0)
.addNumericField("minimumSpeed")
.addNumericField("maximumSpeed")
.addNumericField("averageSpeed")
.addNumericField("numberOfVehicles")
val def: IndexDefinition = IndexDefinition().setPrefixes("traffic\_density:")
val opt: redis.clients.jedis.search.IndexOptions = redis.clients.jedis.search.IndexOptions.defaultOptions().setNoStopwords()
rediSearch.ftCreate(INDEX\_TRAFFIC\_DENSITY, opt.setDefinition(def), sc)
Pros:
Cons:
Pros:
Cons:
Both Jedis and Redisson provide powerful search capabilities with RediSearch. Here’s how to implement search functionality in both clients.
@GetMapping("/redisearch")
fun search(
@RequestParam(required = false) latitude: String? = null,
@RequestParam(required = false) longitude: String? = null,
@RequestParam(required = false, defaultValue = "10") radius: Int = 10,
@RequestParam(required = false) minSpeed: Int? = null,
@RequestParam(required = false) maxSpeed: Int? = null,
@RequestParam(required = false) numberOfVehicles: Int? = null,
@RequestParam(defaultValue = "10") limit: Int = 10,
@RequestParam(defaultValue = "0") offset: Int = 0,
@RequestParam(defaultValue = "lettuce") type: String = "lettuce",
): Map<String, Any> {
val queryBuilder = StringBuilder()
val conditions: MutableList<String> = mutableListOf()
if (latitude != null && longitude != null)
conditions.add(element = "@location:\[$longitude $latitude $radius km\]") // FT.SEARCH traffic\_idx "@location:\[28.887702226638797 41.076237426965875 1 km\]"
if (minSpeed != null || maxSpeed != null)
conditions.add(element = "@averageSpeed:\[${minSpeed} ${maxSpeed}\]")
if (numberOfVehicles != null)
conditions.add(element = "@numberOfVehicles:\[${numberOfVehicles} ${numberOfVehicles}\]")
when {
conditions.isNotEmpty() -> queryBuilder.append(conditions.joinToString(separator = " "))
else -> queryBuilder.append("\*") // If no conditions, default to "\*"
}
val queryString: String = queryBuilder.toString()
var searchDuration: Duration
val totalResults: Long
val searchResults: List<TrafficDensity>
searchDuration = measureTime {
if (type == "jedis") {
val searchResult: redis.clients.jedis.search.SearchResult = rediSearch.ftSearch(INDEX\_TRAFFIC\_DENSITY,
redis.clients.jedis.search.Query(queryBuilder.toString()).limit(offset, limit))
totalResults = searchResult.totalResults
searchResults = searchResult.documents.map { doc: redis.clients.jedis.search.Document ->
val properties: MutableIterable<MutableMap.MutableEntry<String, Any>> = doc.properties
TrafficDensity(
id = UUID.fromString(properties.firstOrNull { it.key == "id" }?.value as? String ?: UUID.randomUUID().toString()),
dateTime = doc.get("dateTime") as String,
latitude = properties.firstOrNull { it.key == "latitude" }?.value as? String ?: "",
longitude = properties.firstOrNull { it.key == "longitude" }?.value as? String ?: "",
geohash = properties.firstOrNull { it.key == "geohash" }?.value as? String ?: "",
minimumSpeed = (properties.firstOrNull { it.key == "minimumSpeed" }?.value as? String)?.toIntOrNull() ?: 0,
maximumSpeed = (properties.firstOrNull { it.key == "maximumSpeed" }?.value as? String)?.toIntOrNull() ?: 0,
averageSpeed = (properties.firstOrNull { it.key == "averageSpeed" }?.value as? String)?.toIntOrNull() ?: 0,
numberOfVehicles = (properties.firstOrNull { it.key == "numberOfVehicles" }?.value as? String)?.toIntOrNull() ?: 0
).also { td: TrafficDensity ->
td.location = properties.firstOrNull { it.key == "location" }?.value as? String ?: ""
}
}
} else {
val result: SearchResult = search.search(INDEX\_TRAFFIC\_DENSITY, queryString, QueryOptions.defaults()
.returnAttributes(
ReturnAttribute("id"),
ReturnAttribute("latitude"),
ReturnAttribute("longitude"),
ReturnAttribute("geohash"),
ReturnAttribute("minimumSpeed"),
ReturnAttribute("maximumSpeed"),
ReturnAttribute("averageSpeed"),
ReturnAttribute("numberOfVehicles"),
ReturnAttribute("location"),
ReturnAttribute("dateTime")
)
.limit(offset, limit)
)
totalResults = result.total
searchResults = result.documents.map { doc: Document ->
val attributes: Map<String, Any> = doc.attributes
TrafficDensity(
id = UUID.fromString(attributes\["id"\] as? String ?: UUID.randomUUID().toString()),
dateTime = attributes\["dateTime"\] as String,
latitude = attributes\["latitude"\] as String,
longitude = attributes\["longitude"\] as String,
geohash = attributes\["geohash"\] as String,
minimumSpeed = (attributes\["minimumSpeed"\] as String).toInt(),
maximumSpeed = (attributes\["maximumSpeed"\] as String).toInt(),
averageSpeed = (attributes\["averageSpeed"\] as String).toInt(),
numberOfVehicles = (attributes\["numberOfVehicles"\] as String).toInt(),
).also {
it.location = attributes\["location"\] as String
}
}
}
}
return mapOf(
"searchDuration" to searchDuration.inWholeMilliseconds,
"query" to queryString,
"total" to totalResults,
"results" to searchResults,
"limit" to limit,
"offset" to offset,
)
}
The idea is to build a flexible search query based on user input parameters, allowing for geo-spatial searches, speed filtering, and pagination. Based on the documentation, the query syntax supports complex conditions, making it easy to filter results based on multiple criteria.
Each case can be handled with Jedis or Redisson, but the syntax and API differ slightly. Passing the type parameter allows you to switch between Jedis and Redisson implementations seamlessly.
Find all traffic points within 5km of a coordinate using Jedis:
GET /api/redisearch?latitude=41.076&longitude=28.887&radius=5&type\=jedis
Search for high-traffic areas with specific speed ranges using Redisson:
GET /api/redisearch?latitude=41.076&longitude=28.887&radius=10&minSpeed=30&maxSpeed=60&numberOfVehicles=50&type\=lettuce
Note: In this project, the query parameter type controls the client used by the endpoint. When type=jedis, the Jedis implementation is used. Any other value (including the default lettuce) routes to the Redisson implementation. The name lettuce here is historical and simply selects the Redisson path in our controller. Full examples can be found in src/main/resources/requests.http.
RediSearch supports sophisticated range queries:
Our controller includes a performance comparison endpoint that measures search operations using both clients:
@GetMapping
fun compareEach(): Any {
val allByRediSearch: Duration = measureTime {
search()
}
val countByRepository: Duration = measureTime {
val count: Long = trafficDensityRepository.count()
log.info("countByRepository: $count")
}
val keys: MutableSet<String>?
val allKeysByRedisTemplateExecute: Duration = measureTime {
keys = redisTemplate.execute { connection: RedisConnection ->
var cursor: Cursor<ByteArray?>? = null
val keysTmp: MutableSet<String> = HashSet()
try {
cursor = connection.scan(ScanOptions.scanOptions().match("traffic\_density\*").count(100).build())
while (cursor.hasNext()) {
log.info("Found key: ${String(bytes = cursor.next()!!)}")
val key: String = String(cursor.next()!!) // Convert ByteArray to String
if (connection.type(key.toByteArray()) == DataType.HASH) {
keysTmp.add(element = key)
}
}
} catch (e: Exception) {
log.error("Error getting traffic\_density keys. Error: ${e.localizedMessage}")
} finally {
if (cursor != null && !cursor.isClosed) {
try {
cursor.close()
} catch (e: IOException) {
log.error("Error closing cursor. Error: ${e.localizedMessage}")
}
}
}
keysTmp
}
}
val entityDatas: ArrayList<TrafficDensity> = arrayListOf()
val allByOpsForHashEntries: Duration = measureTime {
for (key: String in keys!!) {
val linkedHashMap: MutableMap<String, Any> = opsForHash.entries(key)
val entityData = TrafficDensity(
id = UUID.fromString(linkedHashMap\["id"\] as String),
dateTime = "${linkedHashMap\["dateTime"\]}",
latitude = linkedHashMap\["latitude"\] as String,
longitude = linkedHashMap\["longitude"\] as String,
geohash = linkedHashMap\["geohash"\] as String,
minimumSpeed = Integer.parseInt("${linkedHashMap\["minimumSpeed"\]}"),
maximumSpeed = Integer.parseInt("${linkedHashMap\["maximumSpeed"\]}"),
averageSpeed = Integer.parseInt("${linkedHashMap\["averageSpeed"\]}"),
numberOfVehicles = Integer.parseInt("${linkedHashMap\["numberOfVehicles"\]}")
)
entityDatas.add(element = entityData)
}
}
val firstElement: TrafficDensity
val allByRepository: Duration = measureTime {
val findAll: MutableList<TrafficDensity> = trafficDensityRepository.findAll()
firstElement = findAll.first()
}
val allByFindAllByLatitudeAndLongitude: Duration = measureTime {
val findAllByLatitudeAndLongitude = trafficDensityRepository.findAllByLatitudeAndLongitude(latitude = firstElement.latitude, longitude = firstElement.longitude)
}
val allKeysByRedisTemplate: Duration = measureTime {
val keys1 = redisTemplate.keys("traffic\_density:\*")
}
return mapOf(
"allByRediSearch" to allByRediSearch.inWholeMilliseconds,
"countByRepository" to countByRepository.inWholeMilliseconds,
"allKeysByRedisTemplateExecute" to allKeysByRedisTemplateExecute.inWholeMilliseconds,
"allByOpsForHashEntries" to allByOpsForHashEntries.inWholeMilliseconds,
"allByRepository" to allByRepository.inWholeMilliseconds,
"allByFindAllByLatitudeAndLongitude" to allByFindAllByLatitudeAndLongitude.inWholeMilliseconds,
"allKeysByRedisTemplate" to allKeysByRedisTemplate.inWholeMilliseconds,
).also { it: Map<String, Long\> ->
log.info("Result: $it")
}
}
This endpoint compares the performance of different search methods, including RediSearch, repository-based queries, and Redis template operations. The results show that RediSearch provides the fastest response times for complex queries, while repository methods are slower due to the overhead of ORM operations.
{
"allByRediSearch":24,
"countByRepository": 67,
"allKeysByRedisTemplateExecute": 32505,
"allByOpsForHashEntries": 16430,
"allByRepository": 20672,
"allByFindAllByLatitudeAndLongitude": 58,
"allKeysByRedisTemplate": 196
}
If the data is big enough, redistemplate will be failed through the java.lang.OutOfMemoryError: Java heap space error, while RediSearch will return the results in less than 30ms. This demonstrates the power of RediSearch for handling large datasets efficiently, especially for geo-spatial.
// Redisson with optimized connection pool
config.useSingleServer().apply {
connectionPoolSize = 16
connectionMinimumIdleSize = 4
timeout = 10\_000
retryAttempts = 5
}
RediSearch module enforces a hard limit to prevent excessive memory usage. To fix it, you typically need to:
FT.CONFIG SET MAXSEARCHRESULTS 100000` or 0 to disable it all
With 829,001 records, Redis may experience memory pressure, causing:
By implementing these strategies, you can ensure consistent search results even with large datasets, while maintaining the performance benefits that RediSearch provides.
RediSearch with Spring Boot and Kotlin provides a powerful combination for building high-performance search applications. The choice between Jedis and Redisson depends on your specific requirements:
Both clients successfully demonstrate RediSearch’s capabilities in our traffic density search application, providing sub-30ms search responses for geo-spatial queries on large datasets.
The key to success with RediSearch is understanding your data access patterns, designing appropriate indexes, and choosing the right client library for your team’s expertise and application requirements.
Source Code
Full code example can be found in github