Archive for the ‘bugs’ Category

Two subtle bugs in OUTER JOIN queries

Август 3rd, 2010

OUTER JOIN queries in SQL are susceptible to two very subtle bugs that I’ve observed a number of times in the real world. Daniel and I have been hammering out ways to automatically detect queries that suffer from these bugs, in a relatively new Maatkit tool called mk-query-advisor. It’s part of our series of advisor tools for MySQL. I wrote a blog post about it a while ago. Automated analysis of bad query patterns is a good thing to write tools to do, because catching buggy queries is hard work if you do it manually.

Let’s dive right in and analyze these subtle bugs. Warning: if you don’t understand how SQL handles NULL, you’re not going to understand the following. Many people have a hard time with NULL, which is why these bugs are so hard to understand and avoid. This is one reason why SQL is a hard language to use properly.

Bug 1: a column could be NULL for two reasons, and you can’t distinguish them

If the outer table in your query contains NULL-able columns, and you place a WHERE clause to filter out all but those rows, you’re going to get bugs because a non-matching row in the outer table will be all-NULL. Here’s an example. Let’s start with a plain outer join query:

select * from L left join R on l_id = r_id;
+------+------+---------+
| l_id | r_id | r_other |
+------+------+---------+
|    1 |    1 |       5 | 
|    2 |    2 |    NULL | 
|    3 | NULL |    NULL | 
+------+------+---------+

Here we see that one row in the outer table is missing, and one row (the middle row) has a NULL r_other column. Now, let’s add a WHERE clause:

select * from L left join R on l_id = r_id where r_other is null;
+------+------+---------+
| l_id | r_id | r_other |
+------+------+---------+
|    2 |    2 |    NULL | 
|    3 | NULL |    NULL | 
+------+------+---------+

This query is buggy, because the two rows are returned for completely different reasons, and you can’t be sure which is which. IS NULL clauses can safely be placed on the columns used in the JOIN clause, but not on other columns in the outer table that might be NULL.

Bug 2: an OUTER JOIN is converted to INNER

If you place a non-null-safe comparison operator on any column in the outer table that isn’t part of the JOIN clause, you implicitly disable the outer-ness of the query and convert it to an INNER JOIN. Here’s an example:

select * from L left join R on l_id = r_id where r_other > 1;
+------+------+---------+
| l_id | r_id | r_other |
+------+------+---------+
|    1 |    1 |       5 | 
+------+------+---------+

The left-outer-ness of the above query is what causes the third row to be output in the first query I showed you above. The greater-than operator in this example automatically makes the left-ness impossible, because anytime there’s a row in the inner table that has no match in the outer table, it’ll be filled in with NULLs, and those NULLs will be eliminated by the operator. So the effect is that only matching rows will ever be output.

If you want to ponder variations and subtleties of the above, you can read more discussion on the issue report where we’re hammering out the details of automatically detecting and warning about these sneaky errors.

Related posts:

  1. How to simulate FULL OUTER JOIN in MySQL
  2. How to write a SQL exclusion join
  3. How to write SQL JOIN clauses more compactly
  4. The dangerous subtleties of LEFT JOIN and COUNT() in SQL
  5. How to write INSERT IF NOT EXISTS queries in standard SQL


PlanetMySQL Voting: Vote UP / Vote DOWN

Joining on range? Wrong!

Май 18th, 2010

The problem I am going to describe is likely to be around since the very beginning of MySQL, however unless you carefully analyse and profile your queries, it might easily go unnoticed. I used it as one of the examples in our talk given at phpDay.it conference last week to demonstrate some pitfalls one may hit when designing schemas and queries, but then I thought it could be a good idea to publish this on the blog as well.

To demonstrate the issue let’s use a typical example – a sales query. Our data is a tiny store directory consisting of three very simple tables:

SQL:
  1. CREATE TABLE `products` (
  2.   `prd_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  3.   `prd_name` varchar(32) NOT NULL,
  4.   PRIMARY KEY (`prd_id`),
  5.   KEY `name` (`prd_name`)
  6. )
  7.  
  8. CREATE TABLE `tags` (
  9.   `tag_prd_id` int(10) UNSIGNED NOT NULL,
  10.   `tag_name` varchar(32) NOT NULL,
  11.   PRIMARY KEY (`tag_name`, `tag_prd_id`)
  12. )
  13.  
  14. CREATE TABLE `items_ordered` (
  15.   `itm_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  16.   `itm_prd_id` int(10) UNSIGNED NOT NULL,
  17.   `itm_order_timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  18.   PRIMARY KEY (`itm_id`),
  19.   KEY `itm_prd_id__and__itm_order_timestamp` (`itm_prd_id`,`itm_order_timestamp`)
  20. )

"Please excuse the crudity of this model, I didn't have time to build it to scale or to paint it." -- Dr. Emmett Brown

I populated these tables with enough data to serve our purpose.

Our hypothetical sales query could be to figure out how many LCD TVs were sold yesterday.

SQL:
  1. SELECT        COUNT(1)
  2.        FROM   tags t
  3.               JOIN products p
  4.               ON     p.prd_id = t.tag_prd_id
  5.               JOIN items_ordered i
  6.               ON     i.itm_prd_id    = p.prd_id
  7.        WHERE  t.tag_name             = 'lcd'
  8.        AND    i.itm_order_timestamp>= '2010-05-16 00:00:00'
  9.        AND    i.itm_order_timestamp  <'2010-05-17 00:00:00'
  10. +----------+
  11. | COUNT(1) |
  12. +----------+
  13. |     4103 |
  14. +----------+

Seems like a very successful day! :)

When we look at the data structures it looks quite good – there is index on `tag_name` in `tags`, there is index on (`itm_prd_id`, `itm_order_timestamp`) in `items_ordered` and indexes on other columns used in joins. Let’s verify how the query performed in greater detail:

CODE:
  1. SHOW STATUS LIKE 'Handler_read%';                   
  2.  
  3. +-----------------------+--------+
  4. | Variable_name         | Value  |
  5. +-----------------------+--------+
  6. | Handler_read_first    | 0      |
  7. | Handler_read_key      | 3      |
  8. | Handler_read_next     | 118181 |
  9. | Handler_read_prev     | 0      |
  10. | Handler_read_rnd      | 0      |
  11. | Handler_read_rnd_next | 0      |
  12. +-----------------------+--------+

Somehow this does not look as good as the sales numbers. Query matched 4103 rows, but almost 120000 were scanned. And we have proper indexes on all necessary columns! What does EXPLAIN have to say about this?

CODE:
  1. *************************** 1. row ***************************
  2.            id: 1
  3.   select_type: SIMPLE
  4.         table: t
  5.          type: ref
  6. possible_keys: PRIMARY
  7.           key: PRIMARY
  8.       key_len: 98
  9.           ref: const
  10.          rows: 1
  11.         Extra: Using where; Using index
  12. *************************** 2. row ***************************
  13.            id: 1
  14.   select_type: SIMPLE
  15.         table: p
  16.          type: eq_ref
  17. possible_keys: PRIMARY
  18.           key: PRIMARY
  19.       key_len: 4
  20.           ref: example_db.t.tag_prd_id
  21.          rows: 1
  22.         Extra: Using index
  23. *************************** 3. row ***************************
  24.            id: 1
  25.   select_type: SIMPLE
  26.         table: i
  27.          type: ref
  28. possible_keys: itm_prd_id__and__itm_order_timestamp
  29.           key: itm_prd_id__and__itm_order_timestamp
  30.       key_len: 4
  31.           ref: example_db.p.prd_id
  32.          rows: 10325
  33.         Extra: Using where; Using index

To remind - our structure design is:

SQL:
  1. `itm_prd_id` int(10) UNSIGNED NOT NULL
  2.   `itm_order_timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
  3.   KEY `itm_prd_id__and__itm_order_timestamp` (`itm_prd_id`,`itm_order_timestamp`)

In 3rd row key_len is only 4 bytes, while the full key length is 4 bytes for `itm_prd_id` plus 4 bytes for `itm_order_timestamp`, so 8 bytes in total! Also ref shows only one column being used by the last join.

How should we understand this then? Database reads all ordered items where tag is 'lcd', which totals to about 120000 rows as shown by the counters in SHOW STATUS output above, and then filters out those not matching the date range. A very inefficient approach! MySQL was unable to optimize those simple conditions to match both product id and date range by index and read only the relevant rows.

This affects joins only. When you use a range condition on the first (or the only) table, it works as expected:

SQL:
  1. EXPLAIN
  2. SELECT        COUNT(1)
  3.        FROM   items_ordered i
  4.        WHERE  i.itm_prd_id           = 5
  5.        AND    i.itm_order_timestamp>= '2010-05-16 00:00:00'
  6.        AND    i.itm_order_timestamp  <'2010-05-17 00:00:00'
  7.  
  8. *************************** 1. row ***************************
  9.            id: 1
  10.   select_type: SIMPLE
  11.         TABLE: i
  12.          type: range
  13. possible_keys: itm_prd_id__and__itm_order_timestamp
  14.           KEY: itm_prd_id__and__itm_order_timestamp
  15.       key_len: 8
  16.           ref: NULL
  17.          rows: 1306
  18.         Extra: USING WHERE; USING INDEX

In this case MySQL does not print ref at all, because there is no join, however you can notice key_len is 8 bytes, so the full index length. It means both index columns will be used to execute the query.

There may be many workarounds to this problem, all depends on the specific case you may need to solve. Essentially it always comes down to removing range condition from join one way or another. For our example query this could mean introducing additional DATE column and using it for filtering instead:

SQL:
  1. ALTER TABLE items_ordered ADD itm_order_date DATE NOT NULL, ADD INDEX itm_prd_id__and__itm_order_date (itm_prd_id, itm_order_date);
  2. UPDATE items_ordered SET itm_order_date = DATE(itm_order_timestamp);

Now the rewritten query:

SQL:
  1. EXPLAIN
  2. SELECT        COUNT(1)
  3.        FROM   tags t
  4.               JOIN products p
  5.               ON     p.prd_id = t.tag_prd_id
  6.               JOIN items_ordered i
  7.               ON     i.itm_prd_id = p.prd_id
  8.        WHERE  t.tag_name          = 'lcd'
  9.        AND    i.itm_order_date    = '2010-05-16'
  10.  
  11. *************************** 1. row ***************************
  12.            id: 1
  13.   select_type: SIMPLE
  14.         TABLE: t
  15.          type: ref
  16. possible_keys: PRIMARY
  17.           KEY: PRIMARY
  18.       key_len: 98
  19.           ref: const
  20.          rows: 1
  21.         Extra: USING WHERE; USING INDEX
  22. *************************** 2. row ***************************
  23.            id: 1
  24.   select_type: SIMPLE
  25.         TABLE: p
  26.          type: eq_ref
  27. possible_keys: PRIMARY
  28.           KEY: PRIMARY
  29.       key_len: 4
  30.           ref: example_db.t.tag_prd_id
  31.          rows: 1
  32.         Extra: USING INDEX
  33. *************************** 3. row ***************************
  34.            id: 1
  35.   select_type: SIMPLE
  36.         TABLE: i
  37.          type: ref
  38. possible_keys: itm_prd_id__and__itm_order_timestamp,itm_prd_id__and__itm_order_date
  39.           KEY: itm_prd_id__and__itm_order_date
  40.       key_len: 7
  41.           ref: example_db.p.prd_id,const
  42.          rows: 206494
  43.         Extra: USING WHERE; USING INDEX

This query uses 7 bytes of `itm_prd_id__and__itm_order_date` index – 4 bytes is `itm_prd_id` and 3 bytes is `itm_order_date` (DATE type uses 3 bytes). Also ref shows two columns used in join.

CODE:
  1. SHOW STATUS LIKE 'Handler_read%';                   
  2. +-----------------------+-------+
  3. | Variable_name         | Value |
  4. +-----------------------+-------+
  5. | Handler_read_first    | 0     |
  6. | Handler_read_key      | 3     |
  7. | Handler_read_next     | 4104  |
  8. | Handler_read_prev     | 0     |
  9. | Handler_read_rnd      | 0     |
  10. | Handler_read_rnd_next | 0     |
  11. +-----------------------+-------+

Statistics also look much better.

But remember - different query will likely need a different solution.

You can find several bug reports regarding this problem (e.g. #8569, #19548). Some replies from MySQL indicate this may be eventually fixed in 6.0 or some future version. Others say "it’s a documented behaviour – deal with it". But in the real world this is a serious bug, not a feature, and it needs fixing.


Entry posted by Maciej Dobrzanski | No comment

Add to: delicious | digg | reddit | netscape | Google Bookmarks


PlanetMySQL Voting: Vote UP / Vote DOWN

mysql_upgrade and Innodb Tables

Май 15th, 2010

Upgrading from MySQL 5.0 to MySQL 5.1 or Percona Server 5.1 you may run into issues with mysql_upgrade – it will identify some tables to be upgraded and will attempt to run REPAIR TABLE for them. This will fail with “The storage engine for the table doesn’t support repair” error message. This seems to confuse a lot of people and I’ve seen people doing failsafe upgrade path of dumping and reloading complete database confused by this error message, which of course works, but can take quite a lot of time.

Another solution is to simply run ALTER TABLE tbl ENGINE=INNODB which will rebuild table with new MySQL version and normally will fix issues identified by mysql_upgrade.
You can use mysqlcheck -A –check-upgrade to identify tables which need to be fixed such a way.

With Oracle intentions to make Innodb default storage engine in next MySQL release I’m hopeful minor annoyances like this will be fixed. It should not be that complicated to at least map REPAIR TABLE to null ALTER TABLE which will help with most issues.

There is nice bug filed about it though it only covers documentation aspects.


Entry posted by peter | No comment

Add to: delicious | digg | reddit | netscape | Google Bookmarks


PlanetMySQL Voting: Vote UP / Vote DOWN

Two quick performance tips with MySQL 5.1 partitions

Май 6th, 2010
partitionsWhile I was researching for my partitions tutorial, I came across two hidden problems, which may happen often, but are somehow difficult to detect and even more difficult to fix, unless you know what's going on, and why. I presented both cases during my tutorial, but there were no pictures to convey the mechanics of the problem. Here is the full story.

TO_DAYS() prunes two partitions instead of one


If you are partitioning by date, chances are that you are using TO_DAYS(). And depending on how you have partitioned your table, your queries are as fast as you expect them to be. However, there are cases where your query takes twice as long as it should, and of course this will not make you happy.

For example, in a table partitioned by month, when your query searches for values within one specific month, EXPLAIN PARTITIONS tells you that the search involves two partitions (see figure above). This means that, instead of searching through 1,000,000 rows in one partitions, the partitions engine is searching through 2,000,000 rows in two partitions.
But why? The reasoning, as reported from the developers, is that
This is not a bug, since TO_DAYS() returns NULL for invalid dates, it needs to scan the first partition as well (since that holds all NULL values) for ranges.

Bug#49754: Partitioning by RANGE with TO_DAYS always includes first partition when pruning
This makes sense, from a developer's standpoint. From a user's experience, though, it's a bug.
Anyway, it doesn't do us any good to rant about it. Our query is still twice as slow as we want it. We need to take action. The workaround is to create an empty partition in first position. If we are creating a new table, it's simple. Just say
PARTITION p000 VALUES LESS THAN (0)
and all will be well. The partition pruning mechanism will still find two partitions, but since the first one is empty, it won't impact the performance.
If you have an existing table already partitioned, like in our example, then you need to perform a different operation

Now we have a different first partition, with no records. When we issue the same query, the partition pruning will look at partition p0, but it will skip it because there are no records.

Inserting single rows in partitions is slow


Also this bug is sometimes difficult to detect. If you want to test partitions in MySQL 5.1, probably you will take an existing table and convert it to a partitioned one, or you create a new table and load the contents from a dump. Either way, you are unlikely to insert millions of records with single INSERT statements. These single inserts are slower than bulk inserts in the first place, but with partitions there is an additional penalty. Whenever you insert a record, the partitioning engine locks the entire table. When you insert thousands of records, the partitioning engine will lock all partitions before the insert, and unlock them after the insert. If you have 500 partitions, that's 500 locks and 500 unlocks for every statement. Ouch!
It's a design problem, and it is not likely to be fixed without turning around the whole architecture of partitions. Also in this case, there is a bug report, Partitioning performance drops drastically with hundreds of partitions, although nobody says that this is a feature.
What can you do, then? You have several choices:
  • You can use a bulk insert. Instead of single statements, use INSERT with multiple records, or LOAD DATA INFILE.
  • Explicitly LOCK the table before inserting and UNLOCK it after you finish with all the inserts. This will avoid the overhead, although it won't make your table concurrently accessible until you finish.
  • If you use partitioning only to facilitate heavy queries, consider using a non-partitioned table on the master, and partitioned ARCHIVE tables on the slaves (see figure below).


As I have said many times in my presentations, always benchmark before using partitions in production. Whether you think that it will boost your performance or that it will slow things down, don't trust your instincts, and test. You may be up for a surprise.

PlanetMySQL Voting: Vote UP / Vote DOWN

The MySQL documentation is not always right

Апрель 30th, 2010

Let me premise this post with the statement I think the MySQL documentation is an excellent and highly accurate resource. I think the MySQL docs team do a great job, however like software and people, documentation is not perfect.

As members of the MySQL community you can always contribute to improve the process by reading the documentation and logging any issues as Documentation Bugs.

Some time ago in a discussion with a friend and colleague, we were talking about changes in historical defaults that had been improved finally in MySQL 5.4 The specific discussion was on the new default innodb_buffer_pool_size and we both agreed it increased significantly. One said 1GB, the other said 128MB. Who was right? Well we both were, and we were both inaccurate depending on versions.

Referencing the 5.4 Manual in InnoDB Startup Options and System Variables the current value for Linux is 128M, but for Windows it’s 1GB.

However I was confident I was told in a presentation, perhaps even the keynote the value was 1GB. Firing up my server and seeing the original version I used of 5.4.0 (which was not available on Windows) we find that the default for Linux was 1GB at some time, i.e. the first release.

mysql> show global variables like 'innodb_buffer_pool_size';
+-------------------------+------------+
| Variable_name           | Value      |
+-------------------------+------------+
| innodb_buffer_pool_size | 1073741824 |
+-------------------------+------------+
1 row in set (0.00 sec)

mysql> select 1073741824/1024/1024 as MB;
+---------------+
| MB            |
+---------------+
| 1024.00000000 |
+---------------+
1 row in set (0.00 sec)

mysql> show global variables like 'version%';
+-------------------------+------------------------------+
| Variable_name           | Value                        |
+-------------------------+------------------------------+
| version                 | 5.4.0-beta                   |
| version_comment         | MySQL Community Server (GPL) |
| version_compile_machine | x86_64                       |
| version_compile_os      | unknown-linux-gnu            |
+-------------------------+------------------------------+
4 rows in set (0.00 sec)

I’m not trying to nit pick here, I’m highlighting that MySQL is a evolving product with many different versions and architectures. It’s our job of the MySQL community to help make the documentation the best for all readers. In this above case I’ve not logged the issue, because 5.4 is a defunct product, however if you want an example of how I identified a problem, provided a test case, and saw that my contribution was reviewed, verified and implemented check out Bug #51739 –core-file is not default TRUE (incorrect docs).

In conclusion, always read the documentation but pay special attention to the current version that matches the documentation, and the version you are actually running. Defaults change between versions, e.g. innodb_thread_concurrency is a complex example, and I’ve been caught with a large enterprise client with assuming the default of a Connector/J options as true, when it was in 5.0.6, but in 5.0.5 the version the client was running it was false.

An old saying, “trust by verify” is a good motto to consider.


PlanetMySQL Voting: Vote UP / Vote DOWN

Cloud openness contemplated

Апрель 15th, 2010

I caught some of the keynotes and discussion at the Linux Foundation Collaboration Summit today, and was particularly interested in the panel discussion on open source and cloud computing. While we are used to hearing and talking about how important open source software is to cloud computing (open source giving to cloud computing), moderator John Mark Walker posed the question of whether cloud computing gives back? The discussion also rightfully focused on openness in cloud computing, how open source might or might not translate to cloud openness and the importance of data to be open as well.

The discussion also centered on some issues regarding open standards and how open is open enough for cloud computing? It may depend on who you ask, but I tend to think that the flexibility, interoperability and portability advantages of open source software will dictate its continued use and true openness in the cloud.

However, this is not always the case. When we consider openness in the mobile market, we see that while open source software is going into more and more smartphones and mobile devices, by the time it gets into the product and into the hands of consumers, it ends up closed. This is not necessarily a violation of open source license, either in rule or in spirit, but rather the use, incorporation and reliance on open source alongside proprietary products, strategies and companies, typically under a permissive license. Much of it also has to do with the need, both perceived and real, for control of code in these devices among hardware, software, wireless carrier and other players with a stake.

Another interesting perspective of what open source means, or doesn’t mean, in terms of cloud computing, standards and interoperability comes from the Xen community’s Simon Crosby of Citrix.

One of the most interesting things to watch when considering whether cloud computing gives back to open source is the AGPLv3 license, which is viewed in different ways as both a burden and a boon to network-based, distributed development by various parties. We continue to see vendors, such as mobile software player Funambol, as strong supporters of AGPL while others, such as Google, continue their resistence to it.

The AGPL also came up in the Linux Foundation Collaboration summit panel again, and while I don’t think the license currently serves as the answer to whether cloud computing gives back to open source, we do see some benefits to open source from cloud computing, both in terms of code, projects and communities and the commercial vendors leveraging open source software. In terms of code, large users of open source software projects, such Linux, MySQL, Hadoop, Cassandra, help to raise the profile and credibility of open source. Whether corporations or university campuses, these large users can also be among the most active community participants — driving features and shaking out bugs, and most prolific code contributors — creating features and extensions and enlarging the ecosystem. In terms of commercial open source vendors, cloud computing can also mitigate the challenges of balancing and differentiating free, community versions and separate, paid versions. If the vendor is able to offer support, services or even extensions with the cloud version of its software, it is easily separated from a free, community version that may be available for free, but not from the cloud.

Of course, there is more that cloud computing can do for open source and there is much more that has to be done to ensure true openness in cloud computing, particularly when some existing and emerging defacto standards are anything but open, but for all that open source is to cloud computing, cloud computing seems to be returning the favor to some degree already.


PlanetMySQL Voting: Vote UP / Vote DOWN

Missleading Innodb message on recovery

Февраль 16th, 2010

As I wrote about 2 years ago the feature of Innodb to store copy of master’s position in Slave’s Innodb tablespace got broken. There is a lot of discussions at the corresponding bug report while outcome of the fix remained uncertain for me (the bug is market duplicate while the bugs it seems to be duplicate for describe different issues).
Anyway. The customer came to me today having the following message in the error log after Slave crash while running MySQL 5.1.41 (extra virgin version without XtraDB or Percona Patches)

InnoDB: In a MySQL replication slave the last master binlog file
InnoDB: position 0 10000000, file name mysql-bin.005000
InnoDB: Last MySQL binlog file position 0 20000000, file name ./mysql-bin.003000

He has tried to restart replication from mysql-bin.005000 position 10000000 which failed with “Could not find first log file name in binary log index file” error message

Looking at the Master for this slave I could see its binary log files going only to about 2000, so the binary log file mentioned did not ever exist on this master. What is going on ?
The thing is Innodb does not update this information any more however if it is stored in the tablespace the code is still there to print it. This database was running older MySQL version a while back which was updated to MySQL 5.1 months ago, moved to the new hardware by physical copy and the log file numbers restarted back from 1 but tablespace still contained the ancient data.

I reported the bug on this which should be easy to fix. Otherwise it is easy mistake to make. We also have a patch which restores this information and uses it on slave crash recovery.


Entry posted by peter | No comment

Add to: delicious | digg | reddit | netscape | Google Bookmarks


PlanetMySQL Voting: Vote UP / Vote DOWN

Getting around optimizer limitations with an IN() list

Январь 10th, 2010

There was a discussion on LinkedIn one month ago that caught my eye:

Database search by "within x number of miles" radius?

Anyone out there created a zipcode database and created a "search within x numer of miles" function ?
Thankful for any tips you can throw my way..

J

A few people commented that some solutions wouldn't scale. To understand why these sorts of geographic search queries are problematic in MySQL, it's best to show some execution plans on dummy data:

SQL:
  1. EXPLAIN SELECT * FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40
  2. AND y BETWEEN 50 AND 60 AND col_a = 'set1';
  3. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  4. | id | select_type | TABLE       | type  | possible_keys | KEY       | key_len | ref  | rows | Extra       |
  5. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  6. 1 | SIMPLE      | coordinates | range | x_y_col_a     | x_y_col_a | 38      | NULL | 4032 | USING WHERE |
  7. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  8. 1 row IN SET (0.00 sec)
  9.  
  10. EXPLAIN SELECT * FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40;
  11. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  12. | id | select_type | TABLE       | type  | possible_keys | KEY       | key_len | ref  | rows | Extra       |
  13. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  14. 1 | SIMPLE      | coordinates | range | x_y_col_a     | x_y_col_a | 3       | NULL | 4032 | USING WHERE |
  15. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  16. 1 row IN SET (0.01 sec)
  17.  
  18. SELECT count(*) FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40
  19. AND y BETWEEN 50 AND 60 AND col_a = 'set1';
  20. +----------+
  21. | count(*) |
  22. +----------+
  23. |        1 |
  24. +----------+
  25. 1 row IN SET (0.00 sec)
  26.  
  27. SELECT count(*) FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40;
  28. +----------+
  29. | count(*) |
  30. +----------+
  31. |     1664 |
  32. +----------+
  33. 1 row IN SET (0.01 sec)

Did you notice that we estimate just as many rows on the first EXPLAIN as the second one? That doesn't make any sense! The index covers x,y and col_a and should be eliminating a lot of searching, since there is only one row which meets this condition!

The reason for this is simply a missing feature of the MySQL optimizer - and it has to do with using x BETWEEN 30 and 40 (and it's also true with x >= 30 AND x <= 40). Using a range like this prevents us from using the rest of the index. There is a workaround, but it's not pretty:

SQL:
  1. EXPLAIN SELECT * FROM coordinates WHERE x IN
  2. (30.30,30.61,31.18,31.26,31.72,32.11,32.25,32.30,32.42,32.91,33.27,
  3. 33.69,33.79,33.93,34.62,34.78,35.10,35.41,36.62,36.93,37.17,38.93,39.20,
  4. 39.56,39.84,39.87) AND y IN (59.58,56.81,57.27,54.14,56.43,51.87,54.59,
  5. 59.56,57.42,54.13,56.79,59.45) AND col_a = 'set1';
  6. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  7. | id | select_type | TABLE       | type  | possible_keys | KEY       | key_len | ref  | rows | Extra       |
  8. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  9. 1 | SIMPLE      | coordinates | range | x_y_col_a     | x_y_col_a | 38      | NULL312 | USING WHERE |
  10. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  11. 1 row IN SET (0.00 sec)

The ugliest thing about this, is that in real life you wouldn't know all your possible values of X or Y, and so you may end up with a very big IN list. The workaround to this, is to create steppings of the value X and Y that we can use for indexes:

SQL:
  1. ALTER TABLE coordinates ADD x_floor INT NOT NULL, ADD y_floor INT NOT NULL, DROP INDEX x_y_col_a,
  2. ADD INDEX x_floor_y_floor_col_a (x_floor, y_floor, col_a);
  3.  
  4. UPDATE coordinates SET x_floor = FLOOR(x), y_floor = FLOOR(y);
  5.  
  6. EXPLAIN SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  7. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60) AND col_a = 'set1'\G
  8. *************************** 1. row ***************************
  9.            id: 1
  10.   select_type: SIMPLE
  11.         TABLE: coordinates
  12.          type: range
  13. possible_keys: x_floor_y_floor_col_a
  14.           KEY: x_floor_y_floor_col_a
  15.       key_len: 40
  16.           ref: NULL
  17.          rows: 121
  18.         Extra: USING WHERE
  19. 1 row IN SET (0.00 sec)

Fantastic! The only remaining problem with this query is that it's not quite identical to our original. In this query 60.79 will be floored to 60 (and erroneously included in our results):

SQL:
  1. SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  2. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60) AND col_a = 'set1';
  3. +-----+-------+-------+-------+-------+---------+---------+
  4. | id  | x     | y     | col_a | col_b | x_floor | y_floor |
  5. +-----+-------+-------+-------+-------+---------+---------+
  6. | 144 | 33.79 | 54.59 | set1  | NULL  |      33 |      54 |
  7. 38 | 39.20 | 60.79 | set1  | NULL  |      39 |      60 |
  8. +-----+-------+-------+-------+-------+---------+---------+
  9. 2 rows IN SET (0.00 sec)

However, this is a quick fix by re-including the original WHERE conditions (we are just no longer using an index on them):

SQL:
  1. EXPLAIN SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  2. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60)
  3. AND col_a = 'set1' AND x BETWEEN 30 AND 40 AND y BETWEEN 50 AND 60\G
  4. *************************** 1. row ***************************
  5.            id: 1
  6.   select_type: SIMPLE
  7.         TABLE: coordinates
  8.          type: range
  9. possible_keys: x_floor_y_floor_col_a
  10.           KEY: x_floor_y_floor_col_a
  11.       key_len: 40
  12.           ref: NULL
  13.          rows: 121
  14.         Extra: USING WHERE
  15. 1 row IN SET (0.00 sec)
  16.  
  17. SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  18. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60)
  19. AND col_a = 'set1' AND x BETWEEN 30 AND 40 AND y BETWEEN 50 AND 60;
  20. +-----+-------+-------+-------+-------+---------+---------+
  21. | id  | x     | y     | col_a | col_b | x_floor | y_floor |
  22. +-----+-------+-------+-------+-------+---------+---------+
  23. | 144 | 33.79 | 54.59 | set1  | NULL  |      33 |      54 |
  24. +-----+-------+-------+-------+-------+---------+---------+
  25. 1 row IN SET (0.00 sec)

Conclusion:
My examples were only on a small amount of data (16 000 rows) that fitted in memory, but the original query would have full table scanned if I didn't use a FORCE INDEX hint. Add more data, and if X can't filter out enough rows by itself this can create a real problem.

Workarounds are all very good, but they also make applications more difficult to maintain. If you really want to do these types of queries, you should give Sphinx a try.


Entry posted by Morgan Tocker | 3 comments

Add to: delicious | digg | reddit | netscape | Google Bookmarks


PlanetMySQL Voting: Vote UP / Vote DOWN

Getting around optimizer limitations with an IN() list

Январь 10th, 2010

There was a discussion on LinkedIn one month ago that caught my eye:

Database search by "within x number of miles" radius?

Anyone out there created a zipcode database and created a "search within x numer of miles" function ?
Thankful for any tips you can throw my way..

J

A few people commented that some solutions wouldn't scale. To understand why these sorts of geographic search queries are problematic in MySQL, it's best to show some execution plans on dummy data:

SQL:
  1. EXPLAIN SELECT * FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40
  2. AND y BETWEEN 50 AND 60 AND col_a = 'set1';
  3. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  4. | id | select_type | TABLE       | type  | possible_keys | KEY       | key_len | ref  | rows | Extra       |
  5. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  6. 1 | SIMPLE      | coordinates | range | x_y_col_a     | x_y_col_a | 38      | NULL | 4032 | USING WHERE |
  7. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  8. 1 row IN SET (0.00 sec)
  9.  
  10. EXPLAIN SELECT * FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40;
  11. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  12. | id | select_type | TABLE       | type  | possible_keys | KEY       | key_len | ref  | rows | Extra       |
  13. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  14. 1 | SIMPLE      | coordinates | range | x_y_col_a     | x_y_col_a | 3       | NULL | 4032 | USING WHERE |
  15. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  16. 1 row IN SET (0.01 sec)
  17.  
  18. SELECT count(*) FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40
  19. AND y BETWEEN 50 AND 60 AND col_a = 'set1';
  20. +----------+
  21. | count(*) |
  22. +----------+
  23. |        1 |
  24. +----------+
  25. 1 row IN SET (0.00 sec)
  26.  
  27. SELECT count(*) FROM coordinates FORCE INDEX (x_y_col_a) WHERE x BETWEEN 30 AND 40;
  28. +----------+
  29. | count(*) |
  30. +----------+
  31. |     1664 |
  32. +----------+
  33. 1 row IN SET (0.01 sec)

Did you notice that we estimate just as many rows on the first EXPLAIN as the second one? That doesn't make any sense! The index covers x,y and col_a and should be eliminating a lot of searching, since there is only one row which meets this condition!

The reason for this is simply a missing feature of the MySQL optimizer - and it has to do with using x BETWEEN 30 and 40 (and it's also true with x >= 30 AND x <= 40). Using a range like this prevents us from using the rest of the index. There is a workaround, but it's not pretty:

SQL:
  1. EXPLAIN SELECT * FROM coordinates WHERE x IN
  2. (30.30,30.61,31.18,31.26,31.72,32.11,32.25,32.30,32.42,32.91,33.27,
  3. 33.69,33.79,33.93,34.62,34.78,35.10,35.41,36.62,36.93,37.17,38.93,39.20,
  4. 39.56,39.84,39.87) AND y IN (59.58,56.81,57.27,54.14,56.43,51.87,54.59,
  5. 59.56,57.42,54.13,56.79,59.45) AND col_a = 'set1';
  6. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  7. | id | select_type | TABLE       | type  | possible_keys | KEY       | key_len | ref  | rows | Extra       |
  8. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  9. 1 | SIMPLE      | coordinates | range | x_y_col_a     | x_y_col_a | 38      | NULL312 | USING WHERE |
  10. +----+-------------+-------------+-------+---------------+-----------+---------+------+------+-------------+
  11. 1 row IN SET (0.00 sec)

The ugliest thing about this, is that in real life you wouldn't know all your possible values of X or Y, and so you may end up with a very big IN list. The workaround to this, is to create steppings of the value X and Y that we can use for indexes:

SQL:
  1. ALTER TABLE coordinates ADD x_floor INT NOT NULL, ADD y_floor INT NOT NULL, DROP INDEX x_y_col_a,
  2. ADD INDEX x_floor_y_floor_col_a (x_floor, y_floor, col_a);
  3.  
  4. UPDATE coordinates SET x_floor = FLOOR(x), y_floor = FLOOR(y);
  5.  
  6. EXPLAIN SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  7. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60) AND col_a = 'set1'\G
  8. *************************** 1. row ***************************
  9.            id: 1
  10.   select_type: SIMPLE
  11.         TABLE: coordinates
  12.          type: range
  13. possible_keys: x_floor_y_floor_col_a
  14.           KEY: x_floor_y_floor_col_a
  15.       key_len: 40
  16.           ref: NULL
  17.          rows: 121
  18.         Extra: USING WHERE
  19. 1 row IN SET (0.00 sec)

Fantastic! The only remaining problem with this query is that it's not quite identical to our original. In this query 60.79 will be floored to 60 (and erroneously included in our results):

SQL:
  1. SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  2. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60) AND col_a = 'set1';
  3. +-----+-------+-------+-------+-------+---------+---------+
  4. | id  | x     | y     | col_a | col_b | x_floor | y_floor |
  5. +-----+-------+-------+-------+-------+---------+---------+
  6. | 144 | 33.79 | 54.59 | set1  | NULL  |      33 |      54 |
  7. 38 | 39.20 | 60.79 | set1  | NULL  |      39 |      60 |
  8. +-----+-------+-------+-------+-------+---------+---------+
  9. 2 rows IN SET (0.00 sec)

However, this is a quick fix by re-including the original WHERE conditions (we are just no longer using an index on them):

SQL:
  1. EXPLAIN SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  2. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60)
  3. AND col_a = 'set1' AND x BETWEEN 30 AND 40 AND y BETWEEN 50 AND 60\G
  4. *************************** 1. row ***************************
  5.            id: 1
  6.   select_type: SIMPLE
  7.         TABLE: coordinates
  8.          type: range
  9. possible_keys: x_floor_y_floor_col_a
  10.           KEY: x_floor_y_floor_col_a
  11.       key_len: 40
  12.           ref: NULL
  13.          rows: 121
  14.         Extra: USING WHERE
  15. 1 row IN SET (0.00 sec)
  16.  
  17. SELECT * FROM coordinates WHERE x_floor IN (30,31,32,33,34,35,36,37,38,39,40)
  18. AND y_floor IN (50,51,52,53,54,55,56,57,58,59,60)
  19. AND col_a = 'set1' AND x BETWEEN 30 AND 40 AND y BETWEEN 50 AND 60;
  20. +-----+-------+-------+-------+-------+---------+---------+
  21. | id  | x     | y     | col_a | col_b | x_floor | y_floor |
  22. +-----+-------+-------+-------+-------+---------+---------+
  23. | 144 | 33.79 | 54.59 | set1  | NULL  |      33 |      54 |
  24. +-----+-------+-------+-------+-------+---------+---------+
  25. 1 row IN SET (0.00 sec)

Conclusion:
My examples were only on a small amount of data (16 000 rows) that fitted in memory, but the original query would have full table scanned if I didn't use a FORCE INDEX hint. Add more data, and if X can't filter out enough rows by itself this can create a real problem.

Workarounds are all very good, but they also make applications more difficult to maintain. If you really want to do these types of queries, you should give Sphinx a try.


Entry posted by Morgan Tocker | 3 comments

Add to: delicious | digg | reddit | netscape | Google Bookmarks


PlanetMySQL Voting: Vote UP / Vote DOWN

MySQL Cluster 7.0.8a and 6.3.27a released (source)

Октябрь 7th, 2009
Fixes regression - bug 47844.

Configurator and Sandbox scripts are updated to use this version.

If you build from source then you can upgrade using these scripts:
Upgrade script from 7.0.7 -> 7.0.8a is here.
Upgrade script from 7.0.8 -> 7.0.8a is here.
Upgrade script from 6.3.26-> 6.3.27a is here.

Follow the instructions in this blog post!

PlanetMySQL Voting: Vote UP / Vote DOWN