Giuseppe Maxia is a QA developer in MySQL community team.
A system analyst with 20 years of IT experience, he has worked as a database consultant and designer for several years.
He is a frequent speaker at open source events and he's the author of many articles.
He lives in Sardinia (Italy).
This article explains how to test the performance of a large database with MySQL 5.1, showing the advantages of using partitions.
The test database uses data published by the US Bureau of Transportation Statistics. Currently, the data consists of ~ 113 million records (7.5 GB data + 5.2 GB index).
The data for this exercise comes from the Bureau of Transportation Statistics. The data is provided as CSV files, and they are available from July 1987 to July 2007 (at the time of writing).
The details of getting the data, setting the data structure and loading the data are explained on MySQL Forge.
The test affects a database that is bigger than the amount of RAM in the server, and also the indexes are bigger than the RAM.
The server used for this exercise has 4 GB of RAM, and the size of indexes is over 5 GB.
The reasoning behind this specification is that data warehouses have data collections that are far beyond any reasonable amount of RAM that you can possibly install, occupying several terabytes of storage.
In normal database storage, especially for OLTP, the indexes are cached in RAM, to allow for fast retrieval of records. When the data reaches sizes that can't be contained in the available RAM, we need to use a different approach.
One of MySQL 5.1 main features is partitioning, a technique that divides a table into logical portions to speed-up retrievals.
Using MySQL 5.1 partitions looks simple in principle, but there are some tricky points to be aware of while setting the data for maximum performance.
This article will examine the risks and offer some practical advice to achieve the best performance.
The current implementation of partitioning in MySQL 5.1 is quite simple. You can partition your data by
Depending on your needs, you may choose different partitioning types. In this article we concentrate on range partitioning, which is perhaps the most interesting for data warehousing.
MySQL partitioning has some constraints that you must be aware of if you want to use this feature effectively.
* the partitioning value must be an integer;
* if the table has a unique/primary key, the partitioning column must be part of that key.
The first limitation is the one that has the biggest impact on your design decisions. If the column that you need to use for partitioning is not an integer, you need to use a function to transform it. Some additional constraints apply to partitions, as described in the manual, but we are not concerned about it here.
What is relevant in this context is the usage of date columns for partitioning. Since the native data type is not supported, we must convert the date into an integer. In addition to the list of allowed functions, we must take into account the fact that only two date functions can trigger the partition pruning. Thus, if we have to deal with a date column, we need to use one of them (YEAR
or TO_DAYS
).
When using the YEAR()
function, partitioning is easy, readable, and straightforward.
CREATE TABLE by_year (
d DATE
)
PARTITION BY RANGE (YEAR(d))
(
PARTITION P1 VALUES LESS THAN (2001),
PARTITION P2 VALUES LESS THAN (2002),
PARTITION P3 VALUES LESS THAN (2003),
PARTITION P4 VALUES LESS THAN (MAXVALUE)
)
Partitioning by month is trickier. You can't use the MONTH()
for two reasons:
* you would be limited to 12 partitions, because MONTH does not include the year;
* the MONTH function is not optimized for partition pruning, thus performances would be horrible.
Thus, you need to use the other function that is optimized for partition pruning, TO_DAYS.
CREATE TABLE by_month (
d DATE
)
PARTITION BY RANGE (TO_DAYS(d))
(
PARTITION P1 VALUES LESS THAN (to_days('2001-02-01')), -- January
PARTITION P2 VALUES LESS THAN (to_days('2001-03-01')), -- February
PARTITION P3 VALUES LESS THAN (to_days('2001-04-01')), -- March
PARTITION P4 VALUES LESS THAN (MAXVALUE)
)
That's already less clear to read than the one partitioned by year. What's worse is that the server won't retain your values, but it will only save the corresponding integers. This is what you get for the above table:
show create table by_month\G
*************************** 1. row ***************************
Table: by_month
Create Table: CREATE TABLE `by_month`
`d` date DEFAULT NULL
) ENGINE=MyISAM DEFAULT CHARSET=latin1 /*!50100
PARTITION BY RANGE (TO_DAYS(d))
(
PARTITION P1 VALUES LESS THAN (730882) ENGINE = MyISAM,
PARTITION P2 VALUES LESS THAN (730910) ENGINE = MyISAM,
PARTITION P3 VALUES LESS THAN (730941) ENGINE = MyISAM,
PARTITION P4 VALUES LESS THAN MAXVALUE ENGINE = MyISAM) */
It is advisable to save a copy of the script used to create tables partitioned by month, if you want to have a readable reference of what each partition means.
One common mistake that is made when using tables partitioned using a date column is to query by the same function used for partitioning.
For example if your table was created with the clause
partition by range( YEAR(d) )
and you are told that YEAR and TO_DAYS are optimized for partition pruning, it seems logical to use a query like
SELECT count(*) FROM by_year
WHERE YEAR(d) = 2000; # <-- ERROR !!
The partition pruning does not kick, as you can see from EXPLAIN
explain partitions select count(*) from by_year where year(d) = 2001\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: by_year
partitions: P1,P2,P3,P4
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 30123
Extra: Using where
The query is doing a full table scan. The meaning of "optimized for partition pruning" is that the search will use partitions when that column is used in the WHERE clause.
The right way of querying is
SELECT count(*) FROM by_year
WHERE d BETWEEN '2001-01-01' and '2001-12-31';
And now the partition pruning is being used:
explain partitions select count(*)
from by_year
where d between '2001-01-01' and '2001-12-31'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: by_year
partitions: P2
type: system
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 30123
Extra:
This shows that the query does not cause a full table scan, but it will use only one partition.
One of the problems I had while testing partitioned tables with a large dataset is that I could not get the performance improvement that I was expecting.
In Robin Schumacher's first article on partitioning the examples compare tables without primary keys. The partitioned table wins the contest hands down.
In my current tests, I started by using a table with a primary key, on the ground that this is what normally would happen.
So when I first compared data holding 113 million rows, I used tables with primary keys.
That was a mistake.
A primary key on a table so large that its indexes can't fit in memory is not efficient. To get records from such a table means accessing the disk frequently. Your performance depends completely on the speed of your disk and your processor.
Looking at what others do in data warehousing design, I found out that it's common practice to design large DW sets without using indexes.
In this article we'll see also a performance comparison between partitioned tables with and without primary key.
In this test, I wanted to compare performance of a large dataset using MyISAM, InnoDB, and Archive storage engines.
For each engine, I created one unpartitioned table, with primary key (except for archive) and two partitioned tables, one by month and one by year.
Each topology is tested on a dedicated instance of MySQL server, containing only one database with one table.
For each engine, I start the server, run the set of queries and record their results, and then shut down the server.
The server instances were created using MySQL Sandbox.
ID | storage | partitioned | records | size | notes | loading time (*) |
1 | MyISAM | none | 113 Mil | 13 GB | with PK | 37 min |
2 | MyISAM | by month | 113 Mil | 8 GB | without PK | 19 min |
3 | MyISAM | by year | 113 Mil | 8 GB | without PK | 18 min |
4 | InnoDB | none | 113 Mil | 16 GB | with PK | 63 min |
5 | InnoDB | by month | 113 Mil | 10 GB | without PK | 59 min |
6 | InnoDB | by year | 113 Mil | 10 GB | without PK | 57 min |
7 | Archive | none | 113 Mil | 1.8 GB | no keys | 20 min |
8 | Archive | by month | 113 Mil | 1.8 GB | no keys | 21 min |
9 | Archive | by year | 113 Mil | 1.8 GB | no keys | 20 min |
To compare the effects of partitions on large and small datasets, I created 9 more instances, each containing slightly less than 2 GB of data, to see if the results were different.
The results were recorded in yet another database instance, for better comparison.
Thus, at any given time during the tests, there were two instances running. One containing the results and the one being tested.
The queries used for this test are of two types
SELECT COUNT(*)
FROM table_name
WHERE date_column BETWEEN start_date and end_date
SELECT column_list
FROM table_name
WHERE column1 = x and column2 = y and column3 = z
For each query type, I generated queries for different date ranges. For each range, I generated a set of additional queries on adjacent dates. The first query for each range is cold, meaning that such range was hit for the first time. Subsequent queries on the same range are warm, meaning that the range was already at least partially cached.
The list of queries used for the test is on the Forge
Let's start with the wrong approach first.
My first batch of tests used partitioned tables with a composite primary key, the same used in the original table. The total size of the PK was 5.5 GB. Rather than improving performance, PK slowed down the operations.
Queries with partitioned tables, burdened with the index search through a PK that can't fit in RAM (4 GB) performed poorly. This is a lesson to be remembered. Partitions are useful, but they must be used in the right way.
+--------+-----------------+-----------------+-----------------+
| status | myisam unpart | myisam month | myisam year |
+--------+-----------------+-----------------+-----------------+
| cold | 2.6574570285714 | 2.9169642 | 3.0373419714286 |
| warm | 2.5720722571429 | 3.1249698285714 | 3.1294000571429 |
+--------+-----------------+-----------------+-----------------+
The solution was in front of my eyes, but I refused to see it at first. Look at the results for the same set of queries using the ARCHIVE storage engine.
+--------+----------------+-----------------+-----------------+
| status | archive unpart | archive month | archive year |
+--------+----------------+-----------------+-----------------+
| cold | 249.849563 | 1.2436211111111 | 12.632532527778 |
| warm | 235.814442 | 1.0889786388889 | 12.600520777778 |
+--------+----------------+-----------------+-----------------+
The results for the table partitioned by month are better than the ones I got on the corresponding MyISAM table. More comments about this fact later.
Since the performance on partitioned tables using primary keys was so bad, I decided to jump the gun, and get rid of the primary key.
The key factor is that the primary key for this table is larger than the available key buffer (and larger than the total available RAM in this case). Therefore, any search by key will use the disk.
The new approach was successful. Using partitions only, without primary keys, I got what I wanted. A significant improvement in performance. Tables partitioned by month get a 70 to 90% performance gain.
+--------+------------------+------------------+------------------+
| status | myisam unpart | myisam month | myisam year |
+--------+------------------+------------------+------------------+
| cold | 2.6864490285714 | 0.64206445714286 | 2.6343286285714 |
| warm | 2.8157905714286 | 0.18774977142857 | 2.2084743714286 |
+--------+------------------+------------------+------------------+
To make the difference more visible, I tested with two massive queries that should take advantage of the partition pruning mechanism.
# query 1 -- aggregate by year
SELECT year(FlightDate) as y, count(*)
FROM flightstats
WHERE FlightDate BETWEEN "2001-01-01" and "2003-12-31"
GROUP BY y
# query 2 -- aggregate by month
SELECT date_format(FlightDate,"%Y-%m") as m, count(*)
FROM flightstats
WHERE FlightDate BETWEEN "2001-01-01" and "2003-12-31"
GROUP BY m
The results show a performance gain of 30 to 60% for the table partitioned by month, and a gain of 15 to 30% for the table partitioned by year.
+----------+-----------+-----------+-----------+
| query_id | m_u | m_m | m_y |
+----------+-----------+-----------+-----------+
| 1 | 97.779958 | 36.296519 | 82.327554 |
| 2 | 69.61055 | 47.644986 | 47.60223 |
+----------+-----------+-----------+-----------+
I had some trouble when I moved the test to a different server. The above tests were taken on my home desktop, which uses an Intel Dual Core 2.3 MHz CPU. The new server is much faster, with a dual Xeon 2.66 MHz.
Repeating the above tests, I got this surprise:
+--------+-------------------+-------------+-----------------+
| status | myisam unpart |myisam month | myisam year |
+--------+-------------------+-------------+-----------------+
| cold | 0.051063428571429 | 0.6577062 | 1.6663527428571 |
| warm | 0.063645485714286 | 0.1093724 | 1.2369152285714 |
+--------+-------------------+-------------+-----------------+
The original table, with primary key, is faster than the partitioned ones. The times for partitioned tables are the same I got on the slower server, but the performance of the original table has improved, making partitions unnecessary.
What to do? Since this server seems to take advantage of indexes so well, I added an index on the partitioning column to the partitioned tables.
# original table
create table flightstats (
AirlineID int not null,
UniqueCarrier char(3) not null,
Carrier char(3) not null,
FlightDate date not null,
FlightNum char(5) not null,
TailNum char(8) not null,
ArrDelay double not null,
ArrTime datetime not null,
DepDelay double not null,
DepTime datetime not null,
Origin char(3) not null,
Dest char(3) not null,
Distance int not null,
Cancelled char(1) default 'n',
primary key (FlightDate, AirlineID, Carrier, UniqueCarrier, FlightNum, Origin, DepTime, Dest)
)
# partitioned tables
create table flightstats (
AirlineID int not null,
UniqueCarrier char(3) not null,
Carrier char(3) not null,
FlightDate date not null,
FlightNum char(5) not null,
TailNum char(8) not null,
ArrDelay double not null,
ArrTime datetime not null,
DepDelay double not null,
DepTime datetime not null,
Origin char(3) not null,
Dest char(3) not null,
Distance int not null,
Cancelled char(1) default 'n',
KEY (FlightDate)
)
PARTITION BY RANGE ...
The results were then much more satisfactory, with a 35% performance gain.
+--------+-------------------+-------------------+-------------------+
| status | myisam unp | myisam month | myisam year |
+--------+-------------------+-------------------+-------------------+
| cold | 0.075289714285714 | 0.025491685714286 | 0.072398542857143 |
| warm | 0.064401257142857 | 0.031563085714286 | 0.056638085714286 |
+--------+-------------------+-------------------+-------------------+
Testing partitions has been a tiring experience. Gaining performance improvements is not a straightforward operation. What I assumed was a painless change turned out to be a long trial-and-error process.
Applying a partition change to a table is no guarantee for performance improvement. The gain depends on several factors:
Nothing should be taken for granted.
Depending on the usage of your database, you may get a huge performance gain, or nothing at all. You can also get a performance decrease, if you are not careful.
Consider this: a table partitioned by date with month intervals can get you dreamy performance gains if your queries always include a date range. It can be a full table scan if your queries don't include a date range.
Archive tables achieve a huge performance gain when partitioned. Again, it depends on your usage. Without partitioning, any query to an Archive table is a full table scan. If you have historical data that does not change and you need to perform statistical queries by range, the Archive engine is an excellent choice. It uses 10 to 20% of the original storage, and it can perform better than the original MyISAM or InnoDB table for aggregate queries.
Again, it is all a matter of benchmarks. A well tuned partitioned MyISAM table performs far better than a corresponding Archive table, but it needs ten times more storage.
Partitioning is the key for performance gain with large databases. The definition of large depends on the hardware at your disposal.
Applying partitions blindly is no guarantee of achieving performance improvements, but with the aid of some preliminary benchmarks it can become your perfect solution.
Read and post comments on this article in the MySQL Forums. There are currently 3 comments.