Its the gift that keeps on giving, our data synchronization process!
Well, it keeps on giving to me anyway, because its fuel for the furnaces of this blog. Sometimes finding topics to write about every week can be hard, so its nice when they drop into your lap.
Anyway, the process has started to creak at the seams a bit, because we’re pushing more data through it than ever before.
And when I say creak at the seams, what I mean is that our Read IOPS usage on the underlying database has returned to being consistently ridiculous.
Couldn’t Eat Another Bite
The data synchronization process had been relatively stable over most of 2018. Towards the middle, we scaled the underlying database to allow for the syncing of one of the two biggest data sets in the application, and after a slow rollout, that seemed to be going okay.
Of course, with that success under our belt, we decided to sync the other biggest data set in the application. Living life on the edge.
We ended up getting about half way through because everything started to fall apart again, with similar symptoms to last time (spiking Read IOPS capping out at the maximum allowed burst, which would consume the IO credits and then tank the performance completely). We tried a quick fix of provisioning IOPS (to guarantee performance and remove the tipping point created by the consumption of IO credits), but it wasn’t enough.
The database just could not keep up what was being demanded of it.
I’m A Very Understanding Person
Just like last time, the first step was to have a look at the queries being run and see if there was anything obviously inefficient.
With the slow queries related to the “version” of the remote table mostly dealt with in our last round of improvements, the majority of the slow queries remaining were focused on the part of the process that gets a table “manifest”. The worst offenders were the manifest calls for one of the big tables that we had only started syncing relatively recently. Keep in mind that this table is the “special” one featuring hard deletes (compared to the soft deletes of the other tables), so it was using the manifest functionality a lot more than any of the other tables were.
Having had enough of software level optimizations last time, we decided to try a different approach.
An approach that is probably, by far, the more common approach when dealing with performance issues in a database.
Probably The Obvious Solution
The first time we had performance problems with the database we shied away from implementing additional indexes. At the time, we thought that the indexes that we did have were the most efficient for our query load (being a Clustered Index on the two most selective fields in the schema), and we assumed we would have to look elsewhere for optimization opportunities. Additionally, we were worried that the performance issues might have an underlying cause related to total memory usage, and adding another index (or 10) is just more things to keep in memory.
Having scaled the underlying instance and seeing no evidence that the core problem was memory related, we decided to pull the index lever this time.
Analysis showed that the addition of another index similar to the primary key would allow for a decent reduction in the amount of reads required to service a single request (in that, the index would short circuit the need to read the entire partition of the data set into memory in order to figure out what the max value was for the un-indexed field). A quick replication on our performance testing environment proved it unequivocally, which was nice.
For implementation, its easy enough to use Entity Framework to add an index as part of a database migration, so that’s exactly what we did.
We only encountered two issues, which was nice:
- We didn’t seem to be able to use the concurrent index creation feature in PostgreSQL with the version of EF and Npgsql that we were using (which are older than I would like)
- Some of the down migrations would not consistently apply, no matter what we tried
Neither of those two factors could stop us though, and the indexes were created.
Now we just had to roll them out.
Be Free Indexes, Be Free!
That required a little finesse.
We had a decent number of indexes that we wanted to add, and the datasets we wanted to add them to were quite large. Some of the indexes only took a few minutes to initialise, but others took as long as twenty.
Being that we couldn’t seem to get concurrent index creation working with Entity Framework data migrations, we had to sequence them out one at a time in sequential releases.
Not too hard, but a little bit more time consuming than we originally desired.
Of course, the sync process being what it is, its okay if it goes down for a half hour every now and then, so we just took everything out of service temporarily on each deployment to ensure that the database could focus on the index creation without having worry too much about dealing with the constant flood of requests that it usually gets.
At the end of the day, this round of performance investigation and optimization actually took a hell of a lot less time and effort than the last, but I think that’s kind of to be expected when you’re actively trying to minimise code changes.
With the first few of the indexes deployed, we’ve already seen a significant drop in the Read IOPS of the database, and I think we’re going to be in a pretty good place to continue to sync the remainder of the massive data set that caused the database to choke.
The best indicator of future performance is past data though, so I’m sure there will be another post one day, talking all about the next terrible problem.
And how we solved it of course, because that’s what we do.