Deduplicating Large Data With Rails
Storing and retrieving large chunks of data from your database can be tricky if it’s not done correctly. What happens when you want to store a relatively large document or body of text in your database but that large chunk of text is likely to be identical for thousands of new records? You’re faced with the possibility of storing gigabytes of duplicated data that really doesn’t need to be there.
Here’s the scenario, say you have a piece of software that generates a log file on installation with some key information you would like to track and archive. You push this log file back to your server via an API endpoint and store it in the database. The log file is roughly 100KB in file size, for every 100,000 installs that’s coming close to 10GB of data.
The problem is 80% of these log files are identical, they contain the same information but we treat and store them as though they were unique, wasting disk space and blowing out our table size and tuple count with an unnecessary number of rows.
storage is cheap! store everything!
We all know disk space is cheap, but processing power isn’t, large tables cause large indexes which in turn cause slower queries, require more CPU cycles and mean you need to be more careful about writing your future SQL queries. If you query on a non-indexed field you can trigger a full table scan and your app takes a big performance hit in the process.
People love to say “storage is cheap! store everything!” but moving, loading and managing 100GB back up files is lot more difficult and time consuming than 20GB backup files, so it’s worth optimizing where possible.
So how do we optimize this? We’re going to create three tables, The user table, one for the log files and one that acts as a join table between the users and logs. Instead of having a direct relation between the
Log and the
User, there will be a relation from
User to the
UserLog and from the
UserLog to the
Instead of just saving the log file when we receive it, we will instead hash the contents and perform a look up on this hash in the database to see if we have already seen this exact block of data before. If we get a match on the hash we will instead use the id of that record and throw away the data. This means that if 1000 people generate the exact same log output, we will still only store it once.
Lets have a look at how to set this up
CreateLog < ActiveRecord::Migration
add_index :logs, :checksum
add_index :user_logs, :user_id
add_index :user_logs, :log_id
Ok so we have the base tables we need to deduplicate our data. Now lets see how we would store the log files. (for the sake of a cleaner read, i’m going to omit all the usual boilerplate code you would find in a normal application like validation and authentication and get to the point)
Assuming that we are receiving the logs from an API endpoint somewhere and that we just want to store them for later inspection and return a status code, here is a simple way we could implement it.
class LogController < ApplicationController
log = Log.store(params[:log_data])
user_log = UserLog.create(log_id: log.id, user_id: current_user.id)
render status: 201
class User < ActiveRecord::Base
has_many :logs, through: :user_logs
UserLog < ActiveRecord::Base
class Log < ActiveRecord::Base
key = Digest::MD5.hexdigest(data)
log = Log.find_by_checksum(key)
log = Log.new(data: data, checksum: key)
Log.transaction(requires_new: true) do
rescue ActiveRecord::RecordNotUnique => e
It’s as simple as that. The
Log.store method simply uses
Digest::MD5.hexdigest to calculate an MD5 checksum of the log data to which on the next line we then attempt to look up if the record exists. If it does not, we create a new log file and save it, being sure to wrap the save operation in a transaction just incase another log file with the same content is being stored at the same time.
The User model has a
through: :user_logs relation to the Log model which means when you call
User.logs it performs log look up via the smaller join table which just consists of ids in order to find relevant logs. This essentially resolves to a query that looks something like
logs.id = user_logs.log_id
user_logs.user_id = <user_id>
As you can see from the following console output, trying to store the same data repeatedly will result in the same record being returned and not repeatedly stored.
2.3.1 :004 > log = "some log file text here"
2.3.1 :005 > Log.store(log).id
2.3.1 :007 > Log.store(log).id
2.3.1 :008 > Log.store(log).id
2.3.1 :009 > Log.store(log).id
2.3.1 :010 > new_log = "something else"
2.3.1 :011 > Log.store(new_log).id