Redis Swipe Right

posted 7 years ago

I love Redis. Let's build Tinder with Redis.

Install

$ brew install redis
$ redis-server

Its kind of annoying to have redis take up a terminal window, so alias it to run in the background.

# ~/.bashrc
alias 'redis-start'='redis-server > /dev/null 2>&1 &'

Then starting it is not a problem.

$ redis-start

Design

Redis uses key-value pairs to store data. Unlike traditional data stores, Redis will not generate a primary key for you. You need to choose one. (Pick a string, any string). This means it's up to the application to correctly map data between key-value pairs. There are no joins.

Redis does not separate data into collections. Instead there's one big keyspace. Developers generally use colon-delimited keys to add context. For example tinder:users:aj0strow could be where my user profile is stored.

Note the tinder prefix. It's good practice to prefix every key with a unique namespace so other packages can share the same database without conflicts. For example, background job queues often use redis.

User Profile

When a user signs up with Facebook, the profile is created and default settings filled in. Real Tinder keeps track of images, interests, etc. but let's keep it simple.

{
  id: String,
  name: String,
  birthdate: Date,
  gender: String,
  location: {
    locality: String,
    coords: { lat: String, lon: String }
  },
  settings: {
    gender: String,
    distance: Integer,
    ages: Integer Range
  }
}

Redis works really well when a primary key, like a facebook uid, is already available.

users:$facebook_id

Redis doesn't do nested structured data, but we don't need it for Tinder. The whole profile is seralized as JSON and stored as a string. See commands SET and GET.

SET users:aj0strow '{ json user }'
GET users:aj0strow

The user id should be hashed and stored in a secure session on the client (iPhone) with rotated keys. That's out of the scope of this tutorial tho.

Next, Tinder matches you with people in the area.

Potential Matches

Out of every user on Tinder, only a subset are potential matches. The criteria depend on each user's individual settings. We'll keep a set of users in any given locality, and then filter them on the application server, pushing potential matches onto a queue.

To filter all users in a locality, we need all users in a locality. More schema!

locations:$locality

At the start of each session, we remove the user from the old locality, and add to the new locality. See the SADD and SREM commands.

SREM locations:toronto aj0strow
SADD locations:montreal aj0strow

Even though these are two different operations, the idea is moving my id from one set to the other. It should be atomic. Redis provies transactions for grouping commands. See MULTI and EXEC.

MULTI
SREM locations:toronto aj0strow
SADD locations:montreal aj0strow
EXEC

Transactions have the added benefit that the commands are sent in one round-trip to the Redis server.

One option for reading potential user ids from the set is to grab them all, but that would be taxing for places like Tokyo, with potentially millions of people on Tinder. Instead, we can scan with a cursor. See the SSCAN command.

SSCAN locations:montreal 0 COUNT 100

The return value includes the next value to pass to the cursor, and returned set members. For example "133" could be the next cursor. Then you'd call SSCAN again with 133 instead of 0 to fetch the next 100 set members. Careful: the count and the next cursor value are unrelated. If the next cursor is "0" it's the end of the iteration.

Queue

We need a queue for potential matches for the session. It could be a set with user ids, but it makes more sense to push the whole json profile into the queue because the list is generated each time from a set, so the list should remain unique.

users:$facebook_id:queue

Each time Tinder opens, we clear the queue and rebuild it. (In the future, we would optimize this to only run if the location changes substantially). Deleting the key associated with the list will clear all the contents. See the DEL command.

DEL users:aj0strow:queue

After fetching 100 user ids, each one needs to be looked up and checked for a match. Basically check distance, gender, age (left as an exercise). See the RPUSH command.

RPUSH users:aj0strow:queue '{ json user }'

After a few cursors, it's probably safe to start serving profiles to the user. The queue is continued in the background. Time for swiping left or right.

Skips

We need to keep track of which users like whom. The first case is a skip (swipe left). Sets work well for this.

users:$facebook_id:skips

A naive approach would be to store the skips with the current user. The following would be BAD.

SADD users:aj0strow:skips julia-lang

Instead, we believe in second chances.

SADD users:julia-lang:skips aj0strow

This way, when Julia Lang updates her profile, we need not iterate every users:*:skips key to remove julia-lang from the set. Instead, we remove the skips directly.

Each time Julia changes her profile, we set the ttl (time to live) for her skips to a day's time. That way if she changes her profile too much, it never clears. See EXPIRE.

EXPIRE users:julia-lang:skips 86400

When filtering users for the queue, we needa check to make sure I haven't already skipped the user. See SISMEMBER.

SISMEMBER users:julia-lang:skips aj0strow

Likes

Except you know I'm swiping right for Julia Lang. Let's keep track of likes. For consistency we store likes counterintuitively like skips.

users:$facebook_id:likes

We add the like, and check if she likes me back in a transaction.

MULTI
SADD users:julia-lang:likes aj0strow
SISMEMBER users:aj0strow:likes julia-lang
EXEC

Matches

She likes me back. "You have a match!" Time to add matches to the schema. Back to intuitive key structure.

users:$facebook_id:matches

Tinder sorts conversations by most recent to least recent in activity. Redis ordered sets using milliseconds since the epoch will work for reverse chronological order. See ZADD.

MULTI
ZADD users:aj0strow:matches 1401148915195 julia-lang
ZADD users:julia-lang:matches 1401148915195 aj0strow
EXEC

It's unlikely a user has thousands of matches, so it's safe to grab all the matches without a cursor. We want reverse (descending) order by score (time). See ZREVRANGE.

ZREVRANGE users:aj0strow:matches 0 -1

Messages

We work up the courage to break the ice, and it's time to store messages. The messages belong to both users, so the key must be a combination of our two ids (how romantic, nu?)

messages:$hashcode

Our simple hashing function will combine the ids in alphabetic order with a hash symbol. Facebook ids couldn't have a hash symbol due to urls, so it should be safe.

To add messages, see the LPUSH command to prepend messages.

LPUSH messages:aj0strow#julia-lang '{ json message }'

It's unlikely users will message back and forth thousands of times before exchanging snapchat contacts, so we can safely pull all messages at once from the conversation. See LRANGE.

LRANGE messages:aj0strow#julia-lang 0 -1

Real-Time

Tinder wouldn't be much fun without the real-time aspect of matches and messages. We can accomplish real-time events with Redis pub-sub. See SUBSCRIBE.

SUBSCRIBE aj0strow

Each time a match or message is created, we broadcast the event to each relevate client channel. See PUBLISH.

MULTI
PUBLISH aj0strow '{ json event }'
PUBLISH julia-lang '{ json event }'
EXEC

At the end of the session, clean up the connections. See UNSUBSCRIBE.

UNSUBSCRIBE aj0strow

Final Schema

KEYS

users:$facebook_id (string)
locations:$locality (set)
users:$facebook_id:queue (list)
users:$facebook_id:skips (set)
users:$facebook_id:likes (set)
users:$facebook_id:matches (sorted set)
messages:$hashcode (list)

CHANNELS

$facebook_id

Likely simpler than whatever Tinder actually uses.

Further Learning

We touched on many redis operations, but it can do a lot of interesting things.

  • Check out SINTER for common friends and interests.

  • Check out the List of Client Libraries and implement something.

  • Deploy a Redis instance, monitor it, back it up, clear it, restore it. (Maybe a future article?)

Questions, comments? Tweet @aj0strow.