This is an example overly-slow query:
SELECT COUNT(*) AS expression FROM (SELECT DISTINCT s.sid AS sid, s.value AS value, s.context AS context, t.tid AS tid, t.language AS language, t.translation AS translation, t.uid_entered AS uid_entered, t.time_entered AS time_entered, t.time_changed AS time_changed, t.is_suggestion AS is_suggestion, t.is_active AS is_active, ts.has_suggestion AS has_suggestion, ts.has_translation AS has_translation, u.name AS username, 1 AS expression FROM l10n_server_string s LEFT OUTER JOIN l10n_server_status_flag ts ON s.sid = ts.sid AND ts.language = 'cy' LEFT OUTER JOIN l10n_server_translation t ON ts.sid = t.sid AND ts.language = t.language AND t.is_active = 1 LEFT OUTER JOIN users u ON u.uid = t.uid_entered) subquery;
The query comes from l10n_community_get_strings()
For this one, the results are the same if DISTINCT is omitted. I think a fix might be to move the ->distinct() into if ($release || $project), next to the optional JOIN that this particular query did not hit. Assuming the DISTINCT isn't needed for the first three JOINs, and is needed for $query->innerJoin('l10n_server_line', 'l', 's.sid = l.sid').
That moves this from a full table scan on l10n_server_string and a temporary table to using only indexes and skipping the users table.
(Since this is a pager query, all the JOINs could be omitted in this case, but that would be a larger logic change.)
| Comment | File | Size | Author |
|---|---|---|---|
| #31 | 2600986-31.patch | 1.49 KB | gábor hojtsy |
| #30 | 2600986-30.patch | 1.05 KB | gábor hojtsy |
| #27 | 2600986-27.patch | 986 bytes | gábor hojtsy |
| #18 | interdiff.txt | 518 bytes | gábor hojtsy |
| #18 | 2600986-18.patch | 5.89 KB | gábor hojtsy |
Comments
Comment #2
drummI found there is more than one row for the join on
l10n_server_translation. I posted details at #2601412: l10n_community_approve_string() not marking translations as is_active properly?. If that table really should have onesid,languagecombination withis_active = 1, then theDISTINCTcan indeed be moved.Comment #3
gábor hojtsyIt would have one row for sid, language, is_active = 1 and is_suggestion = 0, but without is_suggestion = 0 and leaving the other conditions, there may be multiple outstanding suggestions (regardless of whether there was an is_active = 1 and is_suggestions = 0 too or not).
Comment #4
drummOk, I think the
DISTINCTmay still be removable. It operates on the whole row, so for a singlesid, we should currently have one row per distinctis_suggestionand all the other columns liketime_changed. As far as I can tell, the rows are effectively distinct anyway.If needed, we can add a targeted
GROUP BYclause, to de-dupe on any important columns.Comment #5
gábor hojtsyts.language = t.language AND t.is_active = 1 may have any number of rows in translations (as many as outstanding suggetions + 1 if there is an approved translation).
I think the result of this query is the number of source strings that have at least one active translation OR suggestion in the given language.(silly)Given all the left joins, though, those do not actually filter this query, so the result is the unique number of sids in the source table, which is constant.
l10n_community_get_strings()OTOH supports various filters, and in case one of them is applied, the results would be quite different. Eg. if only strings that have suggestions in this language or if only strings that were translated by a particular uid.It is true that this default query without filters should not be this complicated for the count query at least, which would speed up the default setup of the translation form at least. However, people usually filter at least by a project/release. Eg. https://localize.drupal.org/translate/drupal8 links to languages filtered to D8 RC2 and only untranslated strings to help people finish those up. For example https://localize.drupal.org/translate/languages/hu/translate?project=dru... for Hungarian.
Comment #6
gábor hojtsyBTW the tables have these indexes:
The UI / API supports filtering by any combination of:
s.sid, s.context (string context), t.uid_entered, l.rid (release), l.pid (project), LIKE on s.value or s.translation, ts.has_translation, ts.has_suggestion, t.is_suggestionthat is pretty rich :)
We can definitely look at what data do we need in the first place if only certain filters are used.
Comment #7
drummYep, removing unnecessary tables is the next step, but they are actually being used when
DISTINCTis there.usersis the only one that would be totally safe to take out of count queries, becauseDISTINCTonuidandnameare redundant.When joining on
l10n_server_line, I think theDISTINCTis still needed.Testing the query without filters should be good to test if the
DISTINCThas an effect. For the example in the issue summary, the results are the same with and without it. Same forfr,hu, andjp. The speedup is 11-12 seconds to 1-2 seconds.Comment #8
drummAttached is a patch to try out for this.
Comment #9
gábor hojtsyThat would not suffice IMHO. Here is a summary of the tables vs. filters:
The translations have non-unique sid because there may be multiple suggestions for a sid (that are is_active). The lines are non-unique because there may be multiple occurrences of the same string in a project / release. In fact this later one is very likely.
So until we need one of the filters that uses the line (release/project) or translation (author, search and is / is not suggestion) tables, we can skip the distinct. Unfortunately it is very typical for a translator to go translate a specific project release. Or a moderator to review suggestions from a specific user or suggestions that contain a specific (incorrect) substring, etc.
We could break the line table to two, sid to project and sid to release if we don't care for usage repetitions, and then we can also abandon the file table (which sits inbetween releases and lines now, but release and project info is denormalized into lines). We would loose some data that we don't actually use now but could, eg. to help translators prioritize projects that are used more in a project vs. one-offs. Or we could have a parallel table structure for quicker lookups for sid to pid and sid to rid, which we keep updated on imports. That would still only help with the project and release filters (which are very typical), not the author or any others. So one could easily run into a temp table then too.
Comment #10
gábor hojtsyI started a deep look at this by updating some docs first. Also removing the no-pager feature because this function is only ever invoked once and that always uses a pager.
Comment #12
gábor hojtsy@drumm: so these are ALL the cases where distincts would be needed. At least the other cases may be temp-table free then (assumed, did not test :).
Comment #13
gábor hojtsyOk committed that for a limited improvement. Hope more is possible :)
Comment #15
drummThis is a great improvement:
The average time is still 1.5-2s, so there is room for improvement, but the spikes are not nearly as high.
I believe the slowest query for the site is now the non-pager/count version of the query from this function, which averages around 4s. A lot of the logged queries are searching with
LIKE, which we can't do a lot to improve. I'll update this issue if I see any specific improvements to be made.Comment #16
gábor hojtsy@drumm: so in #10 I removed the non-pager support on this, because it is only used once and that always has a pager:
So if there is a non-pager version of this query, it would be somewhere else, no?
As for further optimizing the query, I looked at what data is used. In l10n_community_get_strings() if the tid that was found was not a translation, we load the translation if there was one. Because the tid found may be nonexistent or even a "random" one based on a matching suggestion, etc. However, all suggestions are loaded with their respective data in _l10n_community_translate_form_item() in if ($source->has_suggestion). So the output of l10n_community_get_strings() is taken primarily in terms of the sid found and if the tid is an active translation (or was overloaded with one), then that is used to be displayed as the translation. I think we can generally do the loading of the translation data only if needed.
While writing this patch I realized that merely joining the translation table will add duplicates of sids, so not doing the distinct for them generally but only if the translation filters are used still leaves duplicates. So we should not add the translation table when not needed.
This drastically cuts down on the columns queried and only uses 1-1 joins by default unless the line or translation tables are needed to be joined in. Even then it is less columns, so the temp table would be smaller at least :) It also makes sense since we may have found a suggestion in many cases, so their data will be thrown away and the translation loaded in place anyway. So not loading those columns in the first place is fine IMHO for the big table. We can load them for the (max 50) results found.
Comment #18
gábor hojtsyDuh.
Comment #20
gábor hojtsyDeployed that and I'm seeing dramatic improvements in search. On staging it looked like almost instant ;) Even with filters that required distinct. Really interested in monitoring data on how much this one helped :)
Comment #21
gábor hojtsyDepending on those results, we may want to close this :)
Comment #22
drummI don't see a noticeable difference in New Relic. It lumps all the queries from this function together, including those with
LIKEfor searching, so the benefits might be lost in averaging.A sample slow query is:
SELECT DISTINCT s.sid AS sid, s.value AS value, s.context AS context, ts.language AS language, ts.has_suggestion AS has_suggestion, ts.has_translation AS has_translation FROM l10n_server_string s LEFT OUTER JOIN l10n_server_status_flag ts ON s.sid = ts.sid AND ts.language = 'fr' INNER JOIN l10n_server_line l ON s.sid = l.sid WHERE (l.pid = 2) LIMIT 50 OFFSET 0;A quick, but small, win is replacing
l10n_server_line's(pid)index with(pid, sid). This is a little better than the(sid,pid)currently being used sincel.pid = 2is a constant.That makes the explain output
In my very brief and incomplete testing, replacing
DISTINCTwithGROUP BYis a big improvement:No more temporary table and no more full table scan.
Comment #23
gábor hojtsyHm, according to answers on http://stackoverflow.com/questions/581521/whats-faster-select-distinct-o... DISTINCT creates a temporary table and uses it for storing duplicates. GROUP BY does the same, but sortes the distinct results afterwards.. So I am curious how does it solve it. But either way we are not interested in anything but the sid, language and translation stat (has_translation, has_suggestion), and either group by or distinct should keep those, because they should be the same regardless of what is joined in.
Comment #24
gábor hojtsyMaybe that has to do with http://stackoverflow.com/questions/581521/whats-faster-select-distinct-o... that is that the group by would only need to look at the sid which has an index, and the distinct needs to look at the whole row which does not.
Comment #25
drummYes, if we need to group by
ts.has_suggestion, ts.has_translationalso, we are back to a full table scan and temporary table.Explicitly adding
ORDER BY NULLif ordering is not needed doesn't make a difference for this particular query withGROUP BY, but does often save on the sorting step. It may help with other variants of the query.Comment #26
gábor hojtsyRight, we only need a unique sid list, we don't care for uniqueness of other values (that would not even be possible, they are boolean flags). Neither that the combination of values is unique, because the sid itself should already be unique, so the rest should not matter. Do you have a proposed patch for a group by that we can test on staging?
Comment #27
gábor hojtsyHere is a quick patch. I don't think the D7 query API supports setting an ORDER BY NULL, it requires using a specific field name as per the docs at least (it says it requires adding the field before using the orderby). So not trying that out for now...
Comment #28
drummI've been using
->orderBy('NULL'), which does have one use in core forum module. I'm not sure if it is a MySQLism, but it works. That said, ordering by something consistent is always good for paged queries.Comment #29
drummOtherwise, this looks good.
Comment #30
gábor hojtsyLet's do that then.
Comment #31
gábor hojtsyAdded some comments to enlighten future code reviewiers ;)
Comment #34
drummI think this is ready for deployment now.
Comment #35
gábor hojtsyDeployed. Looking forward to monitoring feedback to confirm :D
Comment #36
mlhess commentedIt seems that this query was run yesterday.
Comment #37
gábor hojtsy@mlhess: not sure that that query may be generated by the current code of this function. It does indeed come from localize, but I don't think the function can generate such a query anymore. For starters, the query does not join the users table anymore in a count query under ANY circumstances.
Comment #38
gábor hojtsyIt would be great to also quantify the problem between "things could be more optimal" vs. "OMG this brings all the servers down". That would help prioritize :)