This post is the second part of my earlier post on How to build a fast, good scaling user notification system where we discussed the problem areas of the said system. This post will be mostly about a strategy on the infrastructure to store all those notifications and retrieve them as fast as possible.
The most common approach to store data like user notifications would be to create a table containign a PK id, timestamp when the notification was added and of course some additional meta data, for instance:
CREATE TABLE IF NOT EXISTS `notifications` (
`id` int(10) unsigned NOT NULL auto_increment,
`date_added` timestamp NOT NULL default '0000-00-00 00:00:00',
`user_id` int(10) unsigned NOT NULL,
`content` char(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
Every user entering his/hers notification page (which btw is the most frequently visited page) will most probably generate an sql like this:
SELECT id, date_added, content FROM `notifications` WHERE user_id = 123 ORDER BY date_added DESC limit 0,10
You will need the date_added to show to the user when the event occurred, the PK id to take some additional actions on the event and finally the content of the specified notification, we also have to apply a limit to the query to paginate the results. To make queries faster let us create a covering index for the query e.g. modifying the primary index or adding a separate index:
ALTER TABLE `test`.`notifications` DROP PRIMARY KEY ,
ADD PRIMARY KEY ( `id` , `user_id` , `date_added` )
Don't get me wrong, that above is a perfectly good system to store user notifications, it is simple, pretty fast up to a certain point, but we can do it better.
First let us think about the holding of chronological data. When you insert data in a cronological order, the PK id is always in some correlation with the timestamp of when the event occurred. Both fields are always growing but we don't really need the id, only the timestamp is of some value. What we can do is create an ID before inserting the data:
php:
$micro = explode(' ',microtime());
define('MICRO_ID', $micro[1].substr($micro[0],2,5));
That piece of code defines a constant consisting of 10 digits representing the current timestamp and 5 digits representing the micro timestamp. So we have a unique time-related ID that we insert instead of the auto-incremented id and the date_added, and we get a free! index on the timestamp value. To prepare the new structure first we need to change the `notifications` table:
ALTER TABLE `notifications` DROP `date_added`;
ALTER TABLE `notifications` CHANGE `id` `id` BIGINT( 15 ) NOT NULL;
ALTER TABLE `notifications` DROP PRIMARY KEY ,
ADD PRIMARY KEY ( `id`, `user_id` );
Let me show you an example of an insert in a ORM like Doctrine:
php:
$nt = new Notifications();
$nt->id = MICRO_ID;
$nt->content = 'Some event description';
$nt->user_id = 1;
$nt->save();
Surely ;-) you wonder "How about an multi-server environment?". Well, actually I've seen it work in a a big (100+ servers) environment with just 3 digits of micro time and it worked just great. The key here is to make a combined primary index. The only issue we need to face is when we insert two notifications to the same user in the lifetime of the same program. To be extra careful we can use a ON DUPLICATE KEY clause or simply increment the MICRO_ID constant by 1 via a new variable of course. To construct a proper index, we need to look at the new query retrieving the data:
SELECT id, user_id, content FROM `notifications` WHERE user_id = 123 ORDER BY id DESC LIMIT 0,10
Because of the user specific nature of the query we have to rebuild the primary index. The trick is to make a combined index on the user_id first and the id, which is the pk, second. When you'll try (try it) to make it the other way around, MySQL will have to make a extra pass to sort the values by id, therefore making it a lot slower (I had a performance drop of over 10 times !).
ALTER TABLE `test`.`notifications` DROP PRIMARY KEY ,
ADD PRIMARY KEY ( `user_id` , `id` );
The explain plan for the SELECT query above:
select type: simple;
type: ref;
possible keys: PRIMARY;
key: PRIMARY;
key len: 4;
ref: const;
Extra: Using Where
That looks pretty nice. Our query uses the index we have created earlier and it is very fast, in addition we save a whole lot of space that the timestamp field would take up.
The normalization of the table was left out for last on purpose. We need to divide the `content` field into some smaller ones. The real advantage of this kind of notification system is that the whole text is in a template which is used by a object in the program itself (example hierarchy generated with yuml.me). You can of course use some complicated design patterns to enhance the flexibility of the system but the baseline is pretty simple:
In this example \tThe render() method generates the html for each notification row via the template set seperatly for each subclass and a prepareData() method to make additional processing of raw data. We obviosly need another field in the notification table containing the type id:
ALTER TABLE `notifications` DROP `content`;
ALTER TABLE `notifications` ADD `type_id` TINYINT NOT NULL ,
ADD `info` CHAR( 100 ) NOT NULL;
The info field stores all the ids, of short text information you need e.g. two ids and some text '123||456||some text' that you can split later on in the mentioned prepareData() method before using the render() method. I'm not going to carry on about the implementation specifics, we just need to know the baseline of the system. We have successfully created a table structure and a class hierarchy to select and insert user notifications. The next post will handle about inserting multiple notifications (notify multiple users about an event), and the structures to provide such functionality.
by the way... WYSIWYG in blogger is really lame... thinking about going to wordpress...
Showing posts with label mysql. Show all posts
Showing posts with label mysql. Show all posts
Sunday, January 3, 2010
Friday, January 1, 2010
Get data without reading it - the power of covering indexes in MySQL
It's no real breakthrough, but it can have a very significant performance meaning to use covering indexes. Now you are sure to ask what a covering index really is? The answer is quite simple. If a index can provide all the data a query needs it becomes a covering index for it thus covering all the data the query needs. Still sounds too complicated? Let's have a look at a simple example. Let us suppose a pretty common example. A table with users, ids, birth dates, join dates. For some marketing purposes we need to determine the average birth year of users that joined us from the beginning of 2006:
1. Let's create a simple table:
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL auto_increment,
`birth_date` date NOT NULL,
`joined_date` timestamp NOT NULL default CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci AUTO_INCREMENT=1 ;
1. Let's create a simple table:
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL auto_increment,
`birth_date` date NOT NULL,
`joined_date` timestamp NOT NULL default CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci AUTO_INCREMENT=1 ;
2. We need to insert some random data. To make it a more real-life case I inserted 1 000 000 rows, hope my notebook (which has the test mysql running) won't explode ;-)
INSERT INTO `example_table` (`name`, `birth_date`, `joined_date`) VALUES
[a few pretty much random records]
3. What we need to acomplish is the result of a simple sql query:
SELECT AVG(YEAR(`birth_date`)) FROM `users`
WHERE joined_date >= '2006-01-01 00:00:00'
First I'll try to calculate the average using the AVG() function without any indexes except the PK index. The explain plan looks something like this:
select type: simple,
table: users,
type: all,
possible keys: null,
key_len: null,
ref: null,
rows: 1000000,
extra: Using where
The "null" after the possible_keys indicates that there are no indexes to be used with this select. There is pretty much no difference if the query cache is primed or not if you have to fetch and search for the data making disk I/Os. Let us add two indexes: one for the `birth_year` field and one for the `joined_date` column and see how it looks on the explain plan:
ALTER TABLE `test`.`users` ADD INDEX `i_birth` ( `birth_date` );
ALTER TABLE `test`.`users` ADD INDEX `i_joined` ( `joined_date` );
Explain plan:
You can see that MySQL tried to find a fitting index to execute the query but still it took forever with an empty query cache, an eternity. That's clearly the wrong way to go.
Now we will create a covering index for the said query.
ALTER TABLE `test`.`users` ADD INDEX `i_birth_joined` ( `birth_date`, `joined_date` );
The index has all the fields the query needs to be executed so the explain plan :
[a few pretty much random records]
3. What we need to acomplish is the result of a simple sql query:
SELECT AVG(YEAR(`birth_date`)) FROM `users`
WHERE joined_date >= '2006-01-01 00:00:00'
First I'll try to calculate the average using the AVG() function without any indexes except the PK index. The explain plan looks something like this:
select type: simple,
table: users,
type: all,
possible keys: null,
key_len: null,
ref: null,
rows: 1000000,
extra: Using where
The "null" after the possible_keys indicates that there are no indexes to be used with this select. There is pretty much no difference if the query cache is primed or not if you have to fetch and search for the data making disk I/Os. Let us add two indexes: one for the `birth_year` field and one for the `joined_date` column and see how it looks on the explain plan:
ALTER TABLE `test`.`users` ADD INDEX `i_birth` ( `birth_date` );
ALTER TABLE `test`.`users` ADD INDEX `i_joined` ( `joined_date` );
Explain plan:
select type: simple,
table: users,
type: all,
possible keys: i_joined,
key_len: null,
ref: null,
rows: 1000000,
extra: Using where
You can see that MySQL tried to find a fitting index to execute the query but still it took forever with an empty query cache, an eternity. That's clearly the wrong way to go.
Now we will create a covering index for the said query.
ALTER TABLE `test`.`users` ADD INDEX `i_birth_joined` ( `birth_date`, `joined_date` );
The index has all the fields the query needs to be executed so the explain plan :
select type: simple,
table: users,
type: index,
possible keys: i_birth_joined,
key_len: 7,
ref: null,
rows: 1000000,
extra: Using where; Using index
The changes are quite clear. The covering index we created caused MySQL to use it not only in the search operation but also as a data provider. That speeds up thing a whole lot but it also slows down every INSERT/UPDATE operation with rebuilding a large index.
Summarizing:
While creating a covering indexes is a good practice to speed up select queries, you have to take into account the downside of building/rebuilding large indexes that cover a lot of data, the obvious extra space the indexes take up in memory and disk space, or finally the temptation to make too much indexes, instead of taking some slower queries to the background via e.g. a cron process that fills a cache.
Labels:
mysql
Monday, December 28, 2009
How to avoid InnoDB counter row level locking?
Let's think about a situation where a counter gets updated very often, for example the view counter on a very popular New York Times post. Every time a user refreshes the page, a SQL query updates the counter. It would look something like this:
UPDATE `posts` SET view_counter = view_counter + 1 WHERE id = 123
Now on a very busy site there can be problems with frequent counter row locks. If you send a query like that above, what actually can happen is that InnoDB locks the row so often it produces some deadlocks. It is a pretty inprobable situation in small systems hovever in some high-traffic production evironments, there can be problems with such counters getting out of sync. Ther is a very simple and efficient solution to this problem:
- Create a table with the first field beeing the counter id/number, second field should be the foreign key to the element you really want to count and of course a field with the counter value (default 0),
- For every stressed counter, you feel that could get out of sync, create 10-50 rows ( the number is arbitrary) int the created table with remebering which primary key ids are to what counter.
Now all that's left is getting the numbers added up. We will do that by using a simple aggregate function - GROUP BY + SUM(). Counting 20-50 rows every time, when you want to show the counter, isn't a big load for the database hovewer you still can write a cron that generates a single counter again for select queries.
Labels:
mysql
Subscribe to:
Posts (Atom)