Performance tuning part 3

In part 2, we figured out a benchmark for the low-performant API, now we start to deep dive to see if we can find the root cause and remediation.

From API to N1QL

Now we need to diagnose the API; before finger-pointing to the Database, we need to exclude any extra heavy operation/processing inside the API implementation. By checking the code, we have made sure there is not any extra operation apart from the Couchbase query, but there are two queries within a single API call because it’s a search API with pagination, the response body contains both data and meta { data: { ... }, meta: { total: xx}}.
Essentially for data and meta the API triggers two N1QL queries,

"SELECT *,TOSTRING(META().id) AS id from `member` WHERE _type='User' AND (( `oidc`[0].`sub` = 'b213acd3-a2d6-4c37-9a16-aab293833ee7') OR ( `email` = 'xxx@gmail.com'))"

"SELECT COUNT(META().id) as total from `member` WHERE _type='User' AND (( `oidc`[0].`sub` = 'b213acd3-a2d6-4c37-9a16-aab293833ee7') OR ( `email` = 'xxx@gmail.com'))"

It is not ideal, but considering there is only one record returned from the 1st N1QL and the Where clauses are the same, there should be only a little bit of overhead for the aggregation(COUNT) from the 2nd N1QL. So the performance bottleneck points to the Where clause.

Index, index, index!

Creating the right index — with the right keys, in the right order, and using the right expressions — is critical to query performance in any database system.

We run the N1QL in the Couchbase web console, with the explain statement we can check if the query hit the correct index or not.

{
"plan": {
"#operator": "Sequence",
"~children": [
{
"#operator": "UnionScan",
"scans": [
{
"#operator": "IntersectScan",
"scans": [
{
"#operator": "IndexScan2",
"index": "user_type_idx",
"index_id": "b4f62119f60f3d7d",
"index_projection": {
"primary_key": true
},
"keyspace": "member",
"namespace": "default",
"spans": [
{
"exact": true,
"range": [
{
"high": "\"User\"",
"inclusion": 3,
"low": "\"User\""
}
]
}
],
"using": "gsi"
},
{
"#operator": "IndexScan2",
"index": "federationId_idx",
"index_id": "dcee832f95fcb12f",
"index_projection": {
"primary_key": true
},
"keyspace": "member",
"namespace": "default",
"spans": [
{
"exact": true,
"range": [
{
"high": "\"b213acd3-a2d6-4c37-9a16-aab293833ee7\"",
"inclusion": 3,
"low": "\"b213acd3-a2d6-4c37-9a16-aab293833ee7\""
}
]
}
],
"using": "gsi"
}
]
},
{
"#operator": "IntersectScan",
"scans": [
{
"#operator": "IndexScan2",
"index": "user_email",
"index_id": "e6459d6f91c2577d",
"index_projection": {
"primary_key": true
},
"keyspace": "member",
"namespace": "default",
"spans": [
{
"exact": true,
"range": [
{
"high": "\"xxx@gmail.com\"",
"inclusion": 3,
"low": "\"xxx@gmail.com\""
}
]
}
],
"using": "gsi"
},
{
"#operator": "IndexScan2",
"index": "user_type_idx",
"index_id": "b4f62119f60f3d7d",
"index_projection": {
"primary_key": true
},
"keyspace": "member",
"namespace": "default",
"spans": [
{
"exact": true,
"range": [
{
"high": "\"User\"",
"inclusion": 3,
"low": "\"User\""
}
]
}
],
"using": "gsi"
}
]
}
]
},
{
"#operator": "Fetch",
"keyspace": "member",
"namespace": "default"
},
{
"#operator": "Parallel",
"~child": {
"#operator": "Sequence",
"~children": [
{
"#operator": "Filter",
"condition": "(((`member`.`_type`) = \"User\") and (((((`member`.`oidc`)[0]).`sub`) = \"b213acd3-a2d6-4c37-9a16-aab293833ee7\") or ((`member`.`email`) = \"xxx@gmail.com\")))"
},
{
"#operator": "InitialProject",
"result_terms": [
{
"expr": "self",
"star": true
},
{
"as": "id",
"expr": "to_string((meta(`member`).`id`))"
}
]
},
{
"#operator": "FinalProject"
}
]
}
}
]
},
"text": "SELECT *,TOSTRING(META().id) AS id from `member` WHERE _type='User' AND (( `oidc`[0].`sub` = 'b213acd3-a2d6-4c37-9a16-aab293833ee7') OR ( `email` = 'xxx@gmail.com'))"
}

TL;DR, recapping as below,

  • expA AND ( expB OR expC) is transformed to (expA AND expB) OR (expA AND expC)` which follows the Distributive property.

  • A few indexes are hit: user_type_idx , federationId_idx and user_email so the query didn’t run with a very very slow Primary index scanning.

  • If you are sharp-eyed, you will find user_type_idx is scanned twice, the whole query plan is generated as follows,

UnionScan ( 
IntersectScan("by_user_type", "federationId") ,
IntersectScan("by_user_type", "user_email")
)

Deep dive on index definition

We checked the current index definition,

CREATE INDEX `user_email` ON `member`(`email`)

CREATE INDEX `federationId_idx` ON `member`(((`oidc`[0]).`sub`))

They are indexes on specific fields, but less than efficiency, generally, a single composite index which meets the criteria will be more performant than intersections on multiple singular indexes. i.e. an index contains both _type and email` can provide faster single scan than two scans separately on _type and email.

So we changed the indexes as follows,

CREATE INDEX `federationId_idx` ON `member`(`_type`,((`oidc`[0]).`sub`)) WHERE (`_type` = "User") WITH { "defer_build":true }

CREATE INDEX `user_email` ON `member`(`_type`,`email`) WHERE (`_type` = "User") WITH { "defer_build":true }

The defer_build is a trivial, now we have a wider index(including _type )and the data set of index has also been narrowed down with only User documents by a Where clause.

Let’s see the difference with optimized index.

Single query with email under 20it/s CPU Latency(avg) Latency(p90)
Unoptimized index 20% 62.33ms 95.66ms
Optimized index 10% 37.62ms 53.68ms

image

It will be more obviously with 100 it/s for the singular query.

image
Apart from the metric, the explain statement also explains the IntersectScan is gone.

{
"plan": {
"#operator": "Sequence",
"~children": [
{
"#operator": "UnionScan",
"scans": [
{
"#operator": "IndexScan2",
"index": "federationId_idx",
"index_id": "884e8f1fdc96c64",
"index_projection": {
"primary_key": true
},
"keyspace": "member",
"namespace": "default",
"spans": [
{
"exact": true,
"range": [
{
"high": "\"User\"",
"inclusion": 3,
"low": "\"User\""
},
{
"high": "\"b213acd3-a2d6-4c37-9a16-aab293833ee7\"",
"inclusion": 3,
"low": "\"b213acd3-a2d6-4c37-9a16-aab293833ee7\""
}
]
}
],
"using": "gsi"
},
{
"#operator": "IndexScan2",
"index": "user_email",
"index_id": "3b4b2219e70c4b63",
"index_projection": {
"primary_key": true
},
"keyspace": "member",
"namespace": "default",
"spans": [
{
"exact": true,
"range": [
{
"high": "\"User\"",
"inclusion": 3,
"low": "\"User\""
},
{
"high": "\"xxx@gmail.com\"",
"inclusion": 3,
"low": "\"xxx@gmail.com\""
}
]
}
],
"using": "gsi"
}
]
},
{
"#operator": "Fetch",
"keyspace": "member",
"namespace": "default"
},
{
"#operator": "Parallel",
"~child": {
"#operator": "Sequence",
"~children": [
{
"#operator": "Filter",
"condition": "(((`member`.`_type`) = \"User\") and (((((`member`.`oidc`)[0]).`sub`) = \"b213acd3-a2d6-4c37-9a16-aab293833ee7\") or ((`member`.`email`) = \"xxx@gmail.com\")))"
},
{
"#operator": "InitialProject",
"result_terms": [
{
"expr": "self",
"star": true
},
{
"as": "id",
"expr": "to_string((meta(`member`).`id`))"
}
]
},
{
"#operator": "FinalProject"
}
]
}
}
]
},
"text": "SELECT *,TOSTRING(META().id) AS id from `member` WHERE _type='User' AND (( `oidc`[0].`sub` = 'b213acd3-a2d6-4c37-9a16-aab293833ee7') OR ( `email` = 'xxx@gmail.com'))"
}

Comparison

Now we go back to the original query (the one with OR clause), we start from 20 it/s and increase the rate up to 100 it/s.

image

image

image

What’s the next?

We already had a massive improvement in terms of API performance by eliminating the IntersectScan, but the UnionScan is still there, I don’t have a great idea about how to remove it, but depending on the Expectation of the API RPS and the use case, if the OR pattern is really needed in the where query, I may consider to trigger two N1QLs from the application instead of using UnionScan as a workaround.

Reference