Hi,

I'm having some problems understanding what the query planner is doing and how to convince it to work in the way I want.

Sorry that this is such a long and involved eMail. I've tried to describe my problem and show the steps I have taken in debugging it. I have made some effort to present a minimal and basic illustration of the problem. Either I'm in error and glad to be assisted, or there's some opportunities that the query planner is not taking. Hopefully my detail helps someone who knows more than me get to the bottom of the issue!


My GUI montior is reporting SQLite 3.8.11.1 and my app is using 3.17.0. I get the same result with both.

I've included more complete details of my schema at the bottom of this message but only introduced the bits I'm talking about as I go through.


I'm aiming to write a query that's good for feeding an iterator and for pagination. i.e. I don't want a query that needs to compute the whole result up-front, before returning the first row. I want something where I can execute the query, consume a few rows for a while, close it and then later start a new query with a parameter based on how far I got through and then continue where I left off.

The structure of my dataset is such that once rows are written to the database they are not changed, so there exist a set of parameters where I can do this.


The data is an append only log. The log consists of entries (in the entrys table) and they have monotonically increasing "entry-number"s. The table can store more than one log so there's a "log-id" as well. These entries have some data of their own and each point to zero or more items which are stored in the "items" table. Thus there's a join table "entry-items" which consists of 3 columns, all parts of the primary key, "log-d", "entry-number" and "item-id".

Each entry has a "key". As entries are added to the log, the "latest" entry for a particular key describes the current state of that key. The set of keys and their current state is called "records" and they only exist as a query on the tables. It is this query that I'm trying to plan well.

The idea is for the app user to be able to ask for a cursor on the records and then consume them a page at a time. I'm not particularly bothered which order they keys come out in but it has to be deterministic so that I can restart the query again. It's nice if they come out in "key" order but "entry-number" order will do too. The main property I want is to be able to pass this app-defined cursor over my API boundary without holding open a read transaction on SQLite.

I've started with a log that isn't huge but needs some attention to make it work efficiently. This log has 54,272 entries and 42,737 records at the latest version. I expect to have logs with a few tens of million active records but probably nothing larger than 100 million. Not exactly Big Data ;-) but worthy of indexing.


So, here's my query, annotated with a few numbered points so that I can talk about things. The bits that are commented out get commented in (and out again!) as I discuss my problems with the query plan.

-----
explain query plan
            SELECT
            "entrys"."log-id"       AS "log-id",
            "entrys"."entry-number" AS "entry-number",
            "entrys"."region"       AS "region",
            "entrys"."key"          AS "key",
            "entrys"."timestamp"    AS "timestamp",
            "entry-items"."item-id" AS "item-id",
            "items"."blob"          AS "blob"

            FROM
            (SELECT -- ** 1 **
              MAX("entry-number") AS "entry-number",
              "key"
              FROM "entrys"
              WHERE
              "log-id" = 51 AND
              "region" = "user" AND
              "entry-number" <= 54272
-- AND key > "100009" -- ** 2 **
-- AND key > "145943"
              GROUP BY "key"
                -- order by key -- ** 3 **
                ) AS "specific-entrys"

            LEFT OUTER JOIN "entrys"
            ON
            "specific-entrys"."entry-number" = "entrys"."entry-number"
AND "specific-entrys"."key" = "entrys"."key" -- ** 4 **
AND "user" = "entrys"."region"

            LEFT OUTER JOIN "entry-items"
            ON
            51 = "entry-items"."log-id" AND -- ** 5 **
            "specific-entrys"."entry-number" = "entry-items"."entry-number"

            LEFT OUTER JOIN "items"
            ON
            "items"."item-id" = "entry-items"."item-id"

            WHERE
            "entrys"."log-id" = 51
              -- order by key -- ** 6 **
              ;
-----

Here's the query plan for the query above:

-----
1|0|0|SEARCH TABLE entrys USING COVERING INDEX entrys-log-id-region-key-entry-number-desc (log-id=? AND region=?)
0|0|0|SCAN SUBQUERY 1 AS specific-entrys
0|1|1|SEARCH TABLE entrys USING INDEX entrys-log-id-region-key-entry-number-desc (log-id=? AND region=? AND key=? AND entry-number=?) 0|2|2|SEARCH TABLE entry-items USING COVERING INDEX entry-items-log-id-entry-number-item-id (log-id=? AND entry-number=?)
0|3|3|SEARCH TABLE items USING INTEGER PRIMARY KEY (rowid=?)
-----


The query uses a subselect (** 1 **) to get the latest entry number for each and every key (i.e. to get the list of records). On my test workload this produces 42,737 rows and takes 49ms. The efficiency of this query is not a problem yet: If it has to precompute all the keys up-front then I'm fine with that for now.

The query then joins the result back onto the "entrys" table to get the rest of the per-entry metadata, onto the "entry-items" join table and then onto the "items" table to get one row per entry per item. In my app I coallesce these rows back together again.

The (non-explain version) query takes 679ms to run and produces 42,737 rows because none of the entries in this particular log have more than one item each.

Originally the two lines at (** 5 **) said

-----
"entrys"."log-id" = "entry-items"."log-id" AND
"entrys"."entry-number" = "entry-items"."entry-number"
-----

instead of

-----
51 = "entry-items"."log-id" AND -- ** 5 **
"specific-entrys"."entry-number" = "entry-items"."entry-number"
-----

As far as I can tell these are semantically equivalent but the original version takes ~170 seconds rather than 679ms and the query plan looks like this:

-----
1|0|0|SEARCH TABLE entrys USING COVERING INDEX entrys-log-id-region-key-entry-number-desc (log-id=? AND region=?)
0|0|0|SCAN SUBQUERY 1 AS specific-entrys
0|1|1|SEARCH TABLE entrys USING INDEX entrys-log-id-region-key-entry-number-desc (log-id=? AND region=? AND key=? AND entry-number=?)
0|2|2|SCAN TABLE entry-items
0|3|3|SEARCH TABLE items USING INTEGER PRIMARY KEY (rowid=?)
-----

i.e. it does a SCAN TABLE in the loop rather than using the COVERING INDEX.

Do later version of SQLite manage to spot the constant propagation opportunity?


Adding the lines at (** 4 **) allow the planner to use another index.



The query seems to produce rows ordered by "key". If I want to be able to consume a few rows from the result set and then restart later (perhaps by using something like the clauses at (** 2 **)) then I need to gurantee this property. Adding the WHERE clause at (** 6 **) does this but it changes the query plan thus:

-----
1|0|0|SEARCH TABLE entrys USING COVERING INDEX entrys-log-id-region-key-entry-number-desc (log-id=? AND region=?)
0|0|0|SCAN SUBQUERY 1 AS specific-entrys
0|1|1|SEARCH TABLE entrys USING INDEX entrys-log-id-region-key-entry-number-desc (log-id=? AND region=? AND key=? AND entry-number=?) 0|2|2|SEARCH TABLE entry-items USING COVERING INDEX entry-items-log-id-entry-number-item-id (log-id=? AND entry-number=?)
0|3|3|SEARCH TABLE items USING INTEGER PRIMARY KEY (rowid=?)
0|0|0|USE TEMP B-TREE FOR ORDER BY
-----

i.e. now we have a "USE TEMP B-TREE FOR ORDER BY" which (I imagine!) causes the whole result set to be generated up front before the first row is returned.

Now, without the order by clause in (** 2 **), we're getting the rows out in the correct order as a side effect of the group by clause in (** 1 **) so let's uncomment the order by clause at (** 3 **) instead.


This gets us back to the original, iterative query plan. It's got a subquery that we scan in (sorted) order and then some inner loops which should preserve the ordering of the result set, even if the inner loops produce more rows.

However, we still want to gurantee this property so I uncommented the order by clause at (** 6 **) again. Once again, the "USE TEMP B-TREE FOR ORDER BY" line appears in the query plan.

I think I need this order by clause so that I get the correct behaviour in my app, even if later version of the engine change the query plan. Perhaps I lose my performance properties but at least I still get the correct answer!


How do I get SQLite to recognise that it's already done enough work in with this plan to get the results out in the order I specified and therefore elide the "USE TEMP B-TREE FOR ORDER BY" parts of its plan?


Also, is there a way to find out which order SQLite thinks it is returning the rows in (if any) so that, if one exists, I can construct the order by clause that will cause no change to the query plan?




Thanks for any insight and help you can offer me.
I've been a long time user of SQLite so thanks for all the great work and documentation over the years!





Here's my schema:

-----
sqlite> .schema
CREATE TABLE IF NOT EXISTS "entry-items" ("log-id" NOT NULL , "entry-number" NOT NULL , "item-id" NOT NULL ); CREATE TABLE IF NOT EXISTS "entrys" ("log-id" INTEGER NOT NULL , "entry-number" INTEGER NOT NULL , "region" TEXT NOT NULL , "key" TEXT NOT NULL , "timestamp" INTEGER NOT NULL , PRIMARY KEY ("log-id", "entry-number")); CREATE TABLE IF NOT EXISTS "item-digests" ("item-id" INTEGER NOT NULL , "algorithm" TEXT NOT NULL , "digest" BLOB NOT NULL , PRIMARY KEY ("item-id", "algorithm")); CREATE TABLE IF NOT EXISTS "items" ("item-id" INTEGER PRIMARY KEY NOT NULL UNIQUE , "blob" BLOB NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS "registers" ("log-id" INTEGER PRIMARY KEY NOT NULL UNIQUE , "index-of" INTEGER NOT NULL , "name" TEXT NOT NULL ); CREATE INDEX "entry-items-log-id-entry-number-item-id" ON "entry-items" ("log-id" ASC, "entry-number" ASC, "item-id" ASC); CREATE UNIQUE INDEX "entrys-region-key-entry-number-log-id" ON "entrys" ( "region" ASC, "key" ASC, "entry-number" ASC, "log-id" ASC); CREATE UNIQUE INDEX "item-digests-algorithm-digest" ON "item-digests" ("algorithm" ASC, "digest" ASC); CREATE UNIQUE INDEX "registers-index-of-name" ON "registers" ("index-of" ASC, "name" ASC); CREATE UNIQUE INDEX "entrys-log-id-region-key-entry-number-desc" ON "entrys" ( "log-id" ASC, "region" ASC, "key" ASC, "entry-number" DESC)
-----



Regards,
@ndy

--
andy...@ashurst.eu.org
http://www.ashurst.eu.org/
0x7EBA75FF
_______________________________________________
sqlite-users mailing list
sqlite-users@mailinglists.sqlite.org
http://mailinglists.sqlite.org/cgi-bin/mailman/listinfo/sqlite-users

Reply via email to