cockroachdb / cockroachdb/cockroach

opt: query plans of prepared stmts differ from non-prepared stmts

Open
#94,349 0 comments 0 reactions 0 assignees View on GitHub
A-prepared-stmts C-bug T-sql-queries
Dominant language
Go
Stars
32.5k
Forks
4.1k
PR merge metrics
PR metrics pending

Description

Given the schema:

```sql
CREATE TABLE abc (
a INT PRIMARY KEY,
b INT,
c INT
);

CREATE TABLE def (
d INT PRIMARY KEY,
e INT,
f INT,
INDEX (e, f)
);
```

Consider the plans of two semantically equivalent statements:

#### 1. A non-prepared statement

```sql
EXPLAIN ANALYZE (VERBOSE)
SELECT * FROM abc INNER LOOKUP JOIN def ON true
WHERE b = e AND c = f AND b = 11;
```

```
• project
│ columns: (a, b, c, d, e, f)

└── • lookup join (inner)
│ columns: ("lookup_join_const_col_@7", a, b, c, d, e, f)
│ nodes: n1
│ regions: us-east1
│ actual row count: 0
│ vectorized batch count: 0
│ KV time: 0µs
│ KV contention time: 0µs
│ KV rows read: 0
│ KV bytes read: 0 B
│ KV gRPC calls: 0
│ estimated max memory allocated: 0 B
│ MVCC step count (ext/int): 0/0
│ MVCC seek count (ext/int): 0/0
│ estimated row count: 10 (missing stats)
│ table: def@def_e_f_idx
│ equality: (lookup_join_const_col_@7, c) = (e,f)

└── • render
│ columns: ("lookup_join_const_col_@7", a, b, c)
│ render lookup_join_const_col_@7: 11
│ render a: a
│ render b: b
│ render c: c

└── • filter
│ columns: (a, b, c)
│ nodes: n1
│ regions: us-east1
│ actual row count: 0
│ vectorized batch count: 0
│ estimated row count: 10 (missing stats)
│ filter: b = 11

└── • scan
columns: (a, b, c)
nodes: n1
regions: us-east1
actual row count: 0
vectorized batch count: 0
KV time: 3ms
KV contention time: 0µs
KV rows read: 0
KV bytes read: 0 B
KV gRPC calls: 1
estimated max memory allocated: 20 KiB
MVCC step count (ext/int): 0/0
MVCC seek count (ext/int): 4/4
estimated row count: 1,000 (missing stats)
table: abc@abc_pkey
spans: FULL SCAN
```

#### 2. A prepared statement

```sql
PREPARE p AS
SELECT * FROM abc INNER LOOKUP JOIN def ON true
WHERE b = e AND c = f AND b = $1;

EXPLAIN ANALYZE (VERBOSE)
EXECUTE p(11);
```

```
• lookup join (inner)
│ columns: (a, b, c, d, e, f)
│ nodes: n1
│ regions: us-east1
│ actual row count: 0
│ vectorized batch count: 0
│ KV time: 0µs
│ KV contention time: 0µs
│ KV rows read: 0
│ KV bytes read: 0 B
│ KV gRPC calls: 0
│ estimated max memory allocated: 0 B
│ MVCC step count (ext/int): 0/0
│ MVCC seek count (ext/int): 0/0
│ estimated row count: 10 (missing stats)
│ table: def@def_e_f_idx
│ equality: (b, c) = (e,f)
│ pred: e = 11

└── • filter
│ columns: (a, b, c)
│ nodes: n1
│ regions: us-east1
│ actual row count: 0
│ vectorized batch count: 0
│ estimated row count: 10 (missing stats)
│ filter: b = 11

└── • scan
columns: (a, b, c)
nodes: n1
regions: us-east1
actual row count: 0
vectorized batch count: 0
KV time: 337µs
KV contention time: 0µs
KV rows read: 0
KV bytes read: 0 B
KV gRPC calls: 1
estimated max memory allocated: 20 KiB
MVCC step count (ext/int): 0/0
MVCC seek count (ext/int): 2/2
estimated row count: 1,000 (missing stats)
table: abc@abc_pkey
spans: FULL SCAN
```

The two plans are similar, but there is a critical difference. (2) applies an additional filter in the lookup join, `pred: e = 11`. This filter is unnecessary because we've already held `b` constant to `11` and we've performed a lookup where `b = e`.

### Root Cause

Normalization rules are applied to the prepared plan during `PREPARE`. These rules transform the plan into a new shape, such that some normalization rules (specifically the ones that would apply in a non-prepared statement) cannot apply when constant values are assigned during `EXECUTE`. In other words, the normalization performed during `PREPARE` puts the cached query plan into a state that is not compatible with normalization rules that we want to apply during `EXECUTE`.

In this example, `PushFilterIntoJoinLeftAndRight` pushes `b = $1` and `e = $1` into the left and right sides of the join, respectively, but leaves the `b = e` filter in the parent. I believe this could be fixed by making `PushFilterIntoJoinLeftAndRight` remove the unnecessary `b = e` filter. An easier fix might be to create a rule similar `InlineConstVar` that inlines placeholders instead of constants. This rule would replace `b = e` with `$1 = e`, and I believe this would result in having identical query plans for the non-prepared and prepared queries.

### Impact

The impact of this particular example is minimal. There would be some overhead for evaluating the additional filter, but it'd be likely negligible. However, we've seen more complicated queries where the differences in plans is significant, where a non-prepared query takes hundreds of milliseconds and a prepared query takes tens of seconds.

### Potential Fixes

It'll be difficult to guarantee that a non-prepared statement and a prepared statement always result in the same plan. Any normalization rules that apply during `PREPARE` have the potential to transform the query plan such that other normalization rules will no apply when constants are assigned during `EXECUTE`. With that in mind, some options are:

1. Don't perform any normalization rules during `PREPARE`. In theory, this should make all non-prepared and prepared query plans identical. The downside to this is that unnormalized query plans tend to be quite a bit larger than normalized plans, so they'd take up more space in the cache. We'd also be performing more work during `EXECUTE` to normalize things that would otherwise be normalized during `PREPARE`.
2. Apply a subset of normalization rules during `PREPARE` that we know to be "safe" from putting the cache plan in a bad state that prevents other rules in the future. This would require us to identify these "safe" rules, which is non-trivial.
3. Don't guarantee that non-prepared and prepared statements have identical query plans. Instead, fix specific cases that are known to cause problems (see the potential fixes mentioned above in **Root Cause**).

Jira issue: CRDB-22842

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.