How It Works


~14 min read

๐ŸŽ๏ธ Building pre-owned Car Platform With Valkey - Part 2

About 40โ€“60% of user interactions begin on the listing page: a grid of cars with filters for city, price, make, and features. Each filter change used to run a SQL query with several joins, a second query for the count, and a serializer on top. This post shows how we moved that read path to Valkey. Each filter becomes a sorted set of car ids, a page is the intersection of those sets, one HMGET fetches the cards, and a personalised order is computed in the web server from one unordered set. The list API got 4x faster, and a wrk benchmark served 6x more requests.

We built this in 2021. Part 1 covers the business, the user flow, and how we made the individual car page fast.

What the listing page needs #

The listing page needs three things:

The existing way: Django filters on SQL #

The schema, cut down to what the filters touch: a car has a price, a colour, a locality (which belongs to a city), and a variant. A variant (for example XM) belongs to a model (Nexon), and a model to a make (Tata). Features such as AMT are many-to-many, through a car_features table.

The backend uses Django and Django REST Framework, with django-filter for the filters:

from django_filters import rest_framework as filters

class NumberInFilter(filters.BaseInFilter, filters.NumberFilter):
    """Accepts a comma-separated list, e.g. make=10,12"""

class CarFilterSet(filters.FilterSet):
    """Custom filter class for filtering cars"""
    city_id = NumberInFilter(field_name="locality__city__id", lookup_expr="in")
    price_min = filters.NumberFilter(field_name="price", lookup_expr="gte")
    price_max = filters.NumberFilter(field_name="price", lookup_expr="lte")
    make = NumberInFilter(field_name="varient__model__make__id", lookup_expr="in")
    model = NumberInFilter(field_name="varient__model__id", lookup_expr="in")
    color = NumberInFilter(field_name="color__id", lookup_expr="in")
    features = NumberInFilter(field_name="features__id", lookup_expr="in")
    slug = SlugFilter()  # our own filter, described below

    class Meta:
        model = Car
        fields = [
            "make",
            "model",
            "year",
            "color",
            "price",
            "slug"
        ]
from rest_framework import viewsets, mixins
from rest_framework.filters import OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
from .filters import CarFilterSet

class CarViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
    queryset = Car.objects.all() # django lazy queryset
    serializer_class = CarSerializer # converts django orm objects into json
    filter_backends = (DjangoFilterBackend, OrderingFilter) # django filters
    filterset_class = CarFilterSet # custom filterset
    ordering_fields = ["price", "year"]
    pagination_class = CarPagination

For each request, the ViewSet filters the queryset, cuts one page out of it, and serializes that page to JSON.

One request #

GET https://api.car.com/listing/?city_id=1&price_min=200000&price_max=400000&make=120,121&ordering=price&page=2

SELECT car.id, car.price, car.varient_id, car.color_id, car.locality_id
  FROM car
 INNER JOIN locality ON (car.locality_id = locality.id)
 INNER JOIN varient ON (car.varient_id = varient.id)
 INNER JOIN model ON (varient.model_id = model.id)
 WHERE (locality.city_id IN (1) AND car.price >= 200000 AND car.price <= 400000
        AND model.make_id IN (120, 121))
 ORDER BY car.price ASC
 LIMIT 10   -- page_size
OFFSET 10   -- (page - 1) * page_size; max_page = ceil(count / page_size)

This is the SQL Django generates for the filter set above. It joins only the tables a filter needs: city_id is already a column on locality, and make_id a column on model, so city and make are never joined.

The response contains one page of cars and the total count:

HTTP 200 OK
{
  "count": 1023,
  "next": "https://api.car.com/listing/?page=3",
  "previous": "https://api.car.com/listing/?page=1",
  "results": [
    {
      "id": 1,
      "images": [
        "https://cdn.car.com/1/front.webp",
        "https://cdn.car.com/1/left.webp",
        "https://cdn.car.com/1/right.webp",
        "https://cdn.car.com/1/back.webp"
      ],
      "price": 721000,
      "model_name": "Nexon",
      "varient_name": "XM",
      "link": "/user-cars/1",
      "city_name": "Mumbai",
      "owner": "1st"
    }
    . . . = page_size cars
  ]
}

Slugs for search pages #

For SEO, a listing page can also have a readable URL. A Google search for “used Nexon cars in Mumbai” opens a page with that phrase in its path:

๐ŸŒ https://car.com/used-nexon-cars-in-mumbai
-- client takes path and sends as slug query param to the backend
GET https://api.car.com/listing/?slug=used-nexon-cars-in-mumbai

The SlugFilter splits the slug into tokens and looks each one up in a table to get the query string. Every slug maps to a valid combination of the filters above.

used-nexon-cars-in-mumbai => model=121&city_id=1
id value type filter
1 nexon model 121
2 mumbai city 1
3 4-lakh price 400000
4 tata make 2
5 petrol fuel_type 1

Problems with this approach #

Every request does the same work again. The filter query joins up to four tables. Pagination runs a second query, COUNT(*) with the same joins. The serializer then reads model_name, varient_name and city_name, which needs more joins with select_related, or one query per car without it. Many buyers ask for the same popular filter combinations, such as all cars in Mumbai, and the database answers each one from scratch.

Two simpler fixes do not work well here:

The set of cars that match one filter value stays small.

Filters are sets #

Each filter value picks a set of car ids: the cars in Mumbai, the cars priced โ‚น4โ€“8 L, the cars with AMT. A page is the cars that are in every set the buyer asked for, which is an intersection. A filter with several values, such as make=10,12, is a union. In Figure 1, switch the filters on and off:

Fig. 1Filters are sets
A listing request's four filters drawn as rows of car ids; the cars filled in every row are the page
Sample data: 12 cars. Each filter you switch on is one sorted set in Valkey, one row here. A car is on the page when its column is full in every row.

Valkey1 keeps these sets in memory as sorted sets, one per filter attribute, with the attribute as the score. A range filter is then a range read:

ZRANGEBYSCORE car:price 100000 500000   # cars priced 1โ€“5 L
ZRANGEBYSCORE car:color 5 5             # cars with color_id = 5

These reads are O(log(N) + M), where N is the size of the set and M the number of cars returned. Sorting a list or set with SORT2 instead costs O(N + M * log(M)). Set operations3 (ZINTERSTORE, ZUNIONSTORE) combine the results.

Features are many-to-many, so one car:feature sorted set cannot hold them. We keep one set per feature, car:feature:{id}, with the car ids as members and a score of 1.

The keys #

car: holds the listing JSON #

To serve the cards, we keep one hash, car:, with the car id as the field and the listing JSON as the value. HMGET car: 101 102 103 returns every card on a page in one call. The maximum page size is 35, so that is at most 35 values. (MGET over one string key per car would also be one call.)

A hash uses the compact listpack encoding only while it has at most 512 fields (hash-max-listpack-entries) and every value is at most 64 bytes (hash-max-listpack-value)4. Our car: hash holds every live car, and each value is longer than 64 bytes, so Valkey stores it as a regular hash table. In a test on Valkey 9.1 with 10,000 sample cars, the hash used about 5% less memory than one string key per car. The large savings in Instagram’s post come from many small hashes with short values; the setting it names, hash-zipmap-max-entries, is now called hash-max-listpack-entries.

car:<attribute> holds the indexes #

These are the sorted sets from above: car:price scores by price, car:city by city id, car:make by make id. Each set holds every live car, so it has thousands of members.

Keeping them fresh #

We listen to the database change log. When a detail in the listing JSON changes, we update that car’s field in car:. When a car reaches a terminal state, such as sold or removed, we delete its field from car: and its id from every index set.

Serving one page #

Filtering a sorted set gives the cars in score order, so a price-ordered result can be stored once and paged many times:

  1. Key. Hash the filters into a key such as page:price:{queryhash}. The hash must not depend on parameter order, and it leaves out page and ordering: F('city_id=1&color=3,4') eq F('color=4,3&city_id=1').

  2. Hit. If the key exists, read one page with ZRANGE key start stop (ZREVRANGE for descending order), the total with ZCARD, and the cards with HMGET car: โ€ฆ.

  3. Miss. Build the result from the indexes, store it with a TTL, and continue as for a hit:

    • ZUNIONSTORE for a filter with several values, such as make=10,12:

      ZUNIONSTORE all_cars 3 cars_set_1 cars_set_2 cars_set_3 AGGREGATE MAX
      
    • ZINTERSTORE to combine the filters:

      ZINTERSTORE filtered_cars 2 cars_set_1 cars_set_2 WEIGHTS 1 0
      

      By default, ZINTERSTORE adds up a car’s scores from every input set. Give weight 1 only to the set whose score is the sort key, such as car:price, so the result keeps the price as its score.

The order depends only on car data, so a stored result is the same for every buyer who sends that query, and later pages skip the filters entirely.

We do some set operations in the web server instead, then fetch the cards with HMGET:

def filter_by_price(redis_pipe, price_min='-inf', price_max='+inf'):
    return redis_pipe.zrangebyscore(
        name="car:price",
        min=price_min,
        max=price_max
    )

A miss sends several commands, 12 in the example below. We send them in one pipeline5, so the client does not wait for each reply. This saves network round trips and server work: Valkey reads many commands with one read() system call and writes many replies with one write(). The Valkey docs report up to 10 times more queries per second with long pipelines.

The figure follows one request over 12 sample cars: a miss, a hit for page 2, and what a sale does to a stored result.

Fig. 2One listing request through Valkey
A listing request is turned into a canonical key; on a miss, four index sets are intersected and stored with a TTL; page 2 is a hit; a sold car leaves a stale id in the stored result
Sample data, 3 cars per page. The commands are one way to build the result on the server; every reply shown came from running them on Valkey 9.1 with this data. The key is sha1 of the canonical query, first 18 hex digits. Use โ† and โ†’ to step.

Personalised order #

When the order comes from the recommendation model, it depends on the buyer, and it changes every 45 minutes when we retrain the model. So we do not store the order. The key hashes only the car-related filters, and the value is the unordered set of matching car ids, written with SADD. This helps most on popular base pages, such as all cars in Mumbai.

On each request, the web server reads the set, ranks the ids with the LightFM model it keeps in memory, slices out the requested page in Python, and fetches those cards with HMGET.

A stored order would have to exist once per buyer, or buyer group, and be rewritten after every retrain. The set is one key per query, and a retrain writes nothing to Valkey. The web server uses more CPU instead.

Fig. 3One set, ranked per buyer
One unordered set of nine car ids in Valkey, ranked differently for two buyers and two model versions by LightFM in the web server
Sample scores. The set in Valkey is the same for both buyers and both model versions; only the order in the web server changes.

Limits #

Result #

Production numbers from NewRelic, before and after the change.

NewRelic stats
Listing page 4x faster
NewRelic stats
Dedicated page 4.7x faster
NewRelic stats
Bottleneck moved from database to python

A wrk benchmark of the list API:

Wrk benchmark
We can serve 6x more requests

What we showed #

  1. Django filters on SQL repeat the same joins and the same count for every buyer who asks for the same filters.
  2. A filter value is a set of car ids in a Valkey sorted set, scored by the attribute. A page is the intersection of those sets.
  3. A price-ordered result is stored once with a TTL and paged many times. One HMGET on the car: hash fills the page.
  4. A personalised order is computed per request from one unordered set, so a model retrain writes nothing to Valkey.

  1. Valkey COMMAND Ref
    Command Description
    SET Sets the string value of a key, ignoring its type. The key is created if it doesn’t exist.
    TTL Returns the expiration time in seconds of a key.
    HMSET Sets the values of multiple fields.
    HMGET Returns the values of the given fields in a hash.
    HGETALL Returns all fields and values in a hash.
    SADD Adds one or more members to a set. Creates the key if it doesn’t exist.
    SCARD Returns the number of members in a set.
    SINTER Returns the intersection of multiple sets.
    SINTERSTORE Stores the intersection of multiple sets in a key.
    SISMEMBER Determines whether a member belongs to a set.
    SUNION Returns the union of multiple sets.
    SUNIONSTORE Stores the union of multiple sets in a key.
    ZADD Adds one or more members to a sorted set, or updates their scores. Creates the key if it doesn’t exist.
    ZCARD Returns the number of members in a sorted set.
    ZCOUNT Returns the count of members in a sorted set that have scores within a range.
    ZDIFFSTORE Stores the difference of multiple sorted sets in a key.
    ZINTERSTORE Stores the intersection of multiple sorted sets in a key.
    ZMSCORE Returns the score of one or more members in a sorted set.
    ZRANGE Returns members in a sorted set within a range of indexes.
    ZRANGEBYSCORE Returns members in a sorted set within a range of scores.
    ZRANGESTORE Stores a range of members from a sorted set in a key.
    ZRANK Returns the index of a member in a sorted set ordered by ascending scores.
    ZREM Removes one or more members from a sorted set. Deletes the sorted set if all members were removed.
    ZSCAN Iterates over members and scores of a sorted set.
    ZSCORE Returns the score of a member in a sorted set.
    ZUNION Returns the union of multiple sorted sets.
    ZUNIONSTORE Stores the union of multiple sorted sets in a key.
     ↩︎

  2. https://valkey.io/commands/sort/ ↩︎

  3. https://en.wikipedia.org/wiki/Set_theory ↩︎

  4. https://valkey.io/topics/memory-optimization/ ↩︎

  5. https://valkey.io/topics/pipelining/ ↩︎

Tags:

Hacker News Comments

Loading comments...
Add comment / Upvote on Hacker News