Really using Redis to build fast real-time web apps

July 01, 2011

Sure, you can use Redis just like Memcached, but that’s not really using Redis.

Redis can be a key/value store, and it does that well. But Redis also elegantly presents the basic building blocks of any programming language as a database. We’re talking Strings, Lists, Sets, Sorted Sets, Hashes - alongside other utilities such as Pub/Sub, Transactions, etc.

For one of my apps, The Wiki Game, I’ve used a very large percentage of what Redis provides, and doing so was awesome in terms of effort put in versus resulting improvement.

Really using Redis had this important implication: large bits of logic that originally existed inside my application code are now handled inside Redis itself (in ways smarter and faster than my Python code could do). This post describes in detail the mechanics of The Wiki Game, and how they map onto Redis functionality.

The Web App Features using Redis (how The Wiki Game works)

This section describes the user facing functionality, in order to better understand (in the next section) how these features map onto Redis commands and datatypes.

The Wiki Game is a multi-player, real-time web app that “challenges you to find the connection between 2 separate Wikipedia articles as fast and/or efficiently (minimal clicks) as possible”.

Everything that each User does (clicks, wins, chats, etc) needs to be immediately calculated with respect to existing game data (aka: Player B just won, what place are they?), then broadcasted in real-time to all other active players of the game.

The game has the following requirements (corresponding exact Redis client usage in parenthesis. It’s worth noting I’m currently using Redis-2.2.7 and the redis-python client lib)

  • Serve up static Wikipedia content (most visited content is cached in Redis, the rest are in Amazon S3) as fast as possible. (redis-python set/get)
  • Store, and later get, every page click + timestamp, of each user (redis-python rpush/lrange)
  • Get total clicks for each user so far (redis-python lrange)
  • Keep a sorted-by-win-metric list of winners of the current game (redis-python zset)
  • Keep unique list and count of players inside a given running game room (redis-python sadd/srem/scard)
  • Retain each User’s game played and won count for all games (redis-python hincrby)
  • Retain daily and weekly top stats with expiration lifetimes (redis-python zset + setex)
  • Publish last game User+Game data to another (Subscribing) process, which manages the persisting of all data to MySQL (redis-python PubSub)
* You can now set expiration times directly on datatypes. When I first began the code for the leader-board, this feature was not in Redis, and I had to have a special key that had the expiration. Now it's in Redis 2.2.x, ya!

Redis commands and datatypes (aka how the backend works)

Here we get into the details of my Redis usage, with pseudo-code examples. In fact, the code presented below is just a reduced version of the actual code running in production.

I’m hoping to give a general explanation of my Redis usage, while avoiding getting caught up in details (as everyone knows, production code can get a bit messy; there are lots of corner cases and very specific nuisances that aren’t relevant to understanding broadly what is going on). If anything can be clearer, please let me know in the comments.

In each example, the object REDIS is an instance of the redis-python client. The following functions are actually methods of a large class that has several other less important util methods. This class is instantiated inside several of the (Django) views that make up the running web app. Here goes some pseudo-code…

Player data is stored in Redis Hashes. They provide a convenient and logical way to bunch together related data:

def player(self, player_key, data):
    """Current data about a given player"""
    if case0: return REDIS.hget(player_key, data)
    if case1: return REDIS.hmget(player_key, data)
    if case2: REDIS.hmset(player_key, data)

Here I increment Player games played data counters. This sort of operation is much less desirable to do in a traditional DB:

def player_total_played(self, player_id, game_type, incr):
    "Get/set play count (int) for a given player_id for type game_type"
    if incr: return REDIS.hincrby(player_key, total_played_key)
    total_played=self.player(player_id=player_id, data=total_played_key)
    if total_played:
        return total_played
    else: #get from mysql, set data, and return
        data = {total_played_key:total_played}
        self.player(player_id=player_id, data=data)
     return total_played

Using Redis sorted sets to store ‘games played’ data, retrieving this data in sorted form is extremely useful, and less work on my app’s part:

def player_games_played(self, player_id, game_type=None, start=0, stop=-1):
    "Get/set all 'games_type'(s) that 'player_id' has ever played."
    if game_type:
        REDIS.zadd(player_games_key, game_type, time.time())
    else:
        played = REDIS.zrange(player_games_key, start, stop, desc=True)
        if self.handle_some_corner_case:
                self.do_stuff()
            REDIS.zadd(player_games_key, game_type, last_played_time)
        return played

Using Redis sorted sorts to store a Player’s ‘games won’ data:

def player_games_won(self, winobj=None, start=0, stop=-1, count=False):
    "All games a player has won"
    if case0:
        return REDIS.zadd(player_games_won_key, winobj, win_time)
    if count: return self.get_and_return_count_from_db()
    won = REDIS.zrange(player_games_won_key, start, stop, desc=True)
    if not won:
        for game_won in db_query:
            REDIS.zadd(player_games_won_key, game_won_id, win_time)
        won = REDIS.zrange(player_games_won_key, start, stop, desc=True)
    return games_won

Using Redis hashes to set more Player specific data:

def player_total_won(self, player_id, game_type, incr=False):
    "Get/set the count for Player plus 'game_type' that have been won."
    if incr:
        return REDIS.hincrby(player_key, total_won_key)
    total_won = self.player(player_id=player_id, data=total_won_key)
    if total_won is not None:
        return total_won
    else: #when stats page is initially loaded:
        self.set_initial_player_win_data()

Using Redis lists to set and get the ‘click paths’ of a Player:

def player_win_path(self, player_id, game_uuid, all_clicks=None):
    if all_clicks is None: 
        return REDIS.lrange(player_win_path_key, 0, -1)
    for click in all_clicks:
        REDIS.rpush(key, click)
    REDIS.expire(key, WIN_PATH_LIFETIME)

Redis sets help keep the list of Players in a given game room unique:

def game_players(self, player_id=None, count=False, clear=False):
    """Add/clear/count game players for a given game type"""
    if case0:
        new_to_game = REDIS.sadd(game_players_key, player_id)
        if new_to_game: #remove player from old game, add player to new
            if switched_game: REDIS.srem(old_game_players_key, player_id)
            data = {"current_game":self.game_type}
            self.player(player_id=player_id, data=data)
    if count: return REDIS.scard(game_players_key)
    if clear: return REDIS.delete(game_players_key)

Again using Redis lists to get, set, and find the length of Player’s ‘click paths’:

def player_click(self, clicked_page=None, count=False, all_clicks=False):
    """Get or set players clicks """
    if count: return REDIS.llen(key)
    if all_clicks: return REDIS.lrange(key, 0, -1)
    REDIS.rpush(clicked_page_key, value)
    return REDIS.llen(key)

Redis sorted sets have ordering you can specifiy, which is really convenient when you want to define a custom ordering:

def set_game_winner(self, clicks):
    "Set winner. Order using either 'clicks' or 'time'"
    if is_click_based_game:
        result = REDIS.zrangebyscore(winrank_key, result, result)
    else:
        result = time.time()
    REDIS.zadd(winrank_key, self.player_id, result)

Accessing the last winners for a given game by slicing a sorted set:

def get_winners(self, game_uuid):
    return REDIS.zrange(winrank_key, 0, -1, withscores=True)

Keeping sorted lists of the last several game unique ids:

def get_game_uuid(self, start, end, game_uuid):
    "Manage games via unique ids"
    if not REDIS.zcard(game_order_key):
        # handle edge cases, etc...
        for game in games_from_database:
            REDIS.zadd(game_order_key, game.uuid, game.start_time)
    #check corner cases...
    return REDIS.zrange(key, start, end, desc=True)

Setting current game ids, with cutoff limits on how much data is stored:

def set_current_game_uuid(self, uuid, start_time):
    if REDIS.zcard(game_order_key) > MAX_RECENT_GAMES:
        REDIS.zremrangebyrank(key, 0, 0) #remove oldest
    REDIS.zadd(key, uuid, float(start_time))

Sorted sets with expiration timeouts are perfect for leaderboards:

def leaderboard(self, time_type, game_type, player_id, place):
    "Top players 'game_type', sorted by 'points', time_type is today/week"
    points = settings.WIN_POINTS[place]
    if not REDIS.exists(key_lifetime):
        REDIS.delete(key)
        if time_type == "...":
            REDIS.setex(key_lifetime, 1, time_type_lifetime)
    REDIS.zincrby(key, player_id, amount=points)
    if REDIS.zcard(key) > MAX_TOP_PLAYERS:
        REDIS.zremrangebyrank(key, 0, 0)

Accessing leaderboard points:

def get_leader_points(self, player_id, time_type, game_type):
    return REDIS.zscore(leaders_key, player_id)

Getting the top N leaders is now super easy with Redis sorted sets:

def get_leaders(self, time_type, game_type, start=0, stop=-1):
    return REDIS.zrange(leaders_key, start, stop, desc=True, withscores=True)

Redis can even be used as a simple Messaging Queue, how awesome is that!? Here I send data to another subscribing process for persistence in MySQL:

def get_or_create_game(self):
    "Check current game, if it's over, publish to data-persister process."
    if game_is_over:
        last_game = REDIS.getset(current_game_key, new_game_value)
        self.set_current_game_uuid(game_uuid, start_time)
        REDIS.publish(subscribing_process_key, last_game_data)

Conclusion

A main feature of any real-time web app is broadcasting the current state of the system to all attached clients, as fast as possible. These apps also often need to manage complex state that needs updating before each new broadcast. Using a traditional database to do real-time state management can be quite challenging, compared to the relative ease of using a NoSQL type datastore such as Redis. Using Redis has made this task, for The Wiki Game, a straight forward and enjoyable one.

The simplicity of Redis is, in my opinion, one of its most attractive “features”. Redis has made using a database fun - that’s something surprising to say, I’ve gotta admit, but it’s true. So, in summary, if you think Redis might improve your app, go get hacking!

The main website is Redis.io, you can try out using Redis online here: Try Redis, and lastly, you can learn a lot by following the creator of Redis, Salvatore Sanfilippo, on Twitter: @antirez, who often tweets his real-time thought process of the development and constant improvement of Redis.

Update of sorts: Although I’ve been working on this post for a while, this related (in a more general way) article was recently published. It’s a good read: http://antirez.com/post/take-advantage-of-redis-adding-it-to-your-stack.html