Skip to content

perf(sqlx): cache struct field metadata to avoid per-row tag parsing - #5731

Open
suhuaqin wants to merge 6 commits into
zeromicro:masterfrom
suhuaqin:perf/sqlx-cache-fields
Open

suhuaqin wants to merge 6 commits into
zeromicro:masterfrom
suhuaqin:perf/sqlx-cache-fields

Conversation

@suhuaqin

@suhuaqin suhuaqin commented Aug 14, 2026

Copy link
Copy Markdown

What does this PR do?

unmarshalRows / unmarshalRow re-parse db struct tags, re-walk the whole struct and rebuild the tagged value map for every row of a result set. For wide models (20+ columns) under load this reflection work shows up as a significant CPU hotspot in production profiles (we measured ~27% of service CPU in a logistics service dominated by list queries).

This PR caches the type-level metadata (reflect.Type → flattened field indexes + tag→field mapping) in a sync.Map, the same approach jmoiron/sqlx uses in StructScan ("caches the reflect work of matching columns to struct fields").

Key changes in core/stores/sqlx/orm.go:

  • cachedFields: flattened field indexes, tag-name map, strict count, pointer-field list — built once per reflect.Type
  • pointer fields are initialized from cached index lists, preserving both previous side effects: unwrapFields allocated every settable nil pointer every row (ptrIndex, even for unselected columns), and the tagged map build additionally allocated every visited tagged field's nil pointer (taggedPtrIndex: db:"-" leaves, tagged fields inside db:"-"/unexported embeddings, and fields whose tag a later duplicate overwrote)
  • unmarshalRows matches column names once per result set instead of once per row; the count checks run with it, deferred until after the first row so zero-row results keep returning success
  • getValueInterface is untouched and remains the single place producing scan targets, keeping the **T semantics that let database/sql store NULL as nil
  • strict counting, db:"-" handling, unexported-field errors and the positional untagged path are unchanged

Review follow-up

Addressed kevwan's review:

  1. Empty result sets no longer fail field-count validation. The strict/positional checks are deferred until after the first scanner.Next() — a zero-row query returns success exactly like the previous per-row implementation, whose checks lived inside mapStructFieldsIntoSlice and never ran without a row. Regression test: TestRegressZeroRowsSkipCountValidation.
  2. Timeout test keeps coverage of a deadline expiring during row iteration. TestQueryRowsScanTimeout stays in the original live-deadline shape; the row count went from 10k to 100k. 10k rows scanned in ~2.4ms vs the 2ms budget (~1.2x) which the speedup made racable; 100k rows scan in ~19ms (~10x margin; 500 consecutive runs and 50 -race runs green).
  3. Unexported tagged fields keep failing the scan. An unexported field with a non-empty db tag (including db:"-") fails every scanned row with ErrNotReadableValue even when no column selects it, at the same point (after the pointer side effects) and with the same error precedence (strict count check first) as the old per-row map build. Regression tests: TestRegressUnexportedTaggedFieldErrors (slice + single-row paths, zero rows, precedence) and TestRegressUnexportedTagIgnoreFieldErrors.

Removed code

  • getTaggedFieldValueMap and unwrapFields are superseded by the cached equivalents (no other callers; getValueInterface is kept — it is covered by its own tests and still used for scan targets).

Benchmark

New orm_bench_test.go: 23-column pointer-field struct via sqlmock (mock overhead is identical before/after), go test -bench=BenchmarkQueryRowsPartial -benchmem on an M2, go1.25:

Rows ns/op before ns/op after speedup allocs/op before → after B/op before → after
10 171249 147047 1.16x 1161 → 612 65894 → 29986
100 878486 367485 2.39x 10978 → 5479 611386 → 251355

(1-row numbers are omitted: at that size the benchmark is dominated by the sqlmock driver itself rather than the scanning code.)

Behavior verification

  • Full core/stores/sqlx test suite passes, including TestUnmarshalRowsZeroValueStructPtr (NULL → nil pointer semantics), the strict-mode / embedded-struct cases, and the regression tests above.

Behavior notes (intentional, reviewed)

  • Semantics preserved via two mirrored collections: flat follows the old unwrapFields (strict count, positional path, pointer init), byName follows the old getTaggedFieldValueMap (embeddings flattened regardless of their own tag/export status; db:"-" stays in the name map). Regression tests cover: unexported embedded values in strict mode, all-db:"-" structs silently discarding columns, inner tagged fields of db:"-" embeddings, duplicate tags (later wins), and embedded pointers that cannot be pre-allocated (set → scanned as before; nil → ErrNotReadableValue).
  • Embedded pointers that cannot be pre-allocated: unexported embeddings (reflect cannot set them) and db:"-" embeddings (whose subtree collectFlat skips) are not in the pointer-init list, so they can stay nil. byName paths are resolved with a nil-safe fieldByIndex walk instead of FieldByIndex: a selected column resolving through a nil one fails with ErrNotReadableValue — the previous per-row code panicked there (reflect: Field on zero Value) — while a set pointer's inner tagged fields scan exactly as before (reachable on the single-row path, which scans the caller's struct in place; slice scans always build fresh rows).
  • Tagged-pointer map-build side effect preserved: the per-row getTaggedFieldValueMap ran getValueInterface on every visited tagged field, allocating its nil pointer even when the column was not selected. taggedPtrIndex collects those fields during the collectByName walk (the walk visits overwritten duplicates too) and initPtrFields runs it as a second pass after ptrIndex, via the nil-safe fieldByIndex, skipping paths that cross an unallocated embedded pointer. Regression tests cover the db:"-" leaf, the db:"-" embedding subtree (tagged and db:"-" leaves allocated, untagged pointer stays nil) and the overwritten duplicate inside a db:"-" embedding.
  • Unexported tagged fields: collectByName records unexportedTagged, and buildValues fails the first scanned row with ErrNotReadableValue — exactly the error the old per-row map build raised on every row, independent of column selection, db:"-" included, after the pointer side effects and behind the strict count check (old precedence).
  • Cache design: global sync.Map keyed by reflect.Type, no eviction (entries bounded by the set of scanned model types), concurrent first builds are idempotent duplicates.
  • Test fix included (TestQueryRowsScanTimeout): its assertion implicitly depended on scanning being slower than a live 2ms budget; 10k rows was only ~1.2x over the budget after the field-cache speedup. The row count is now 100k (~10x margin), keeping the deadline reliably expiring mid-iteration in the original test shape.

⚠️ No breaking change: all public APIs and scan semantics are unchanged.

@suhuaqin
suhuaqin force-pushed the perf/sqlx-cache-fields branch from 35bcfb2 to 8b166ef Compare August 14, 2026 08:46
@suhuaqin

suhuaqin commented Aug 14, 2026

Copy link
Copy Markdown
Author

Correction to my earlier note above — my previous measurement methodology was flawed (I compared against a stale checkout, so both sides ran the same code). The actual facts, re-verified against unmodified master:

  • TestQueryRowsScanTimeout passes deterministically on master (3/3 full-suite runs on the same machine).
  • It starts failing intermittently with this PR, because the cached-field scanning is fast enough to occasionally finish 10k mocked rows inside the live 2ms deadline, flipping the DeadlineExceeded assertion. The test's correctness implicitly depended on the implementation being slow.
  • Fixed in 1d8dc8a by using an already-expired deadline, making the assertion independent of scanning speed. Apologies for the confusion in the earlier comment.

苏华钦 added 5 commits September 5, 2026 21:51
unmarshalRows re-parsed db tags, re-walked the struct and rebuilt the
tagged value map for every row. Cache the type-level mapping
(reflect.Type -> field indexes / tag names) in a sync.Map, initialize
pointer fields from the cached index list, and match columns once per
result set instead of per row.

Behavior is preserved:
- nil pointer fields are still allocated for every row, including
  columns that are not selected (previous unwrapFields side effect)
- getValueInterface is still the single place producing scan targets,
  keeping the **T semantics that let database/sql store NULL as nil
- strict counting, db:"-" handling, unexported-field errors and the
  positional untagged path are unchanged (full sqlx test suite passes)

Benchmark (23-column struct via sqlmock, 2s benchtime):
  QueryRowsPartial100: 878486 -> 367485 ns/op (2.39x)
  QueryRowsPartial10:  171249 -> 147047 ns/op (1.16x)
  allocs/op:           10978 -> 5479 (2.0x)
  B/op:                611386 -> 251355 (2.4x)
…eparately

Address review findings on the strict/named edge cases:

- flat (strict count, positional path, pointer init) now follows unwrapFields:
  unexported fields and db:"-" subtrees are skipped entirely
- byName now follows getTaggedFieldValueMap: embedded structs are flattened
  regardless of their own tag or export status, and db:"-" tags stay in the
  name map, so all-ignored structs still discard columns silently and inner
  tagged fields of db:"-" embeddings are still scanned
- tagged unexported fields no longer fail at cache build time; they surface
  ErrNotReadableValue from getValueInterface when a column actually targets
  them, which also restores the old strict-check-before-error precedence
- adds regression tests for all reported shapes plus duplicate-tag resolution
The test relied on scanning 10k mocked rows exceeding a live 2ms deadline.
Once scanning got faster (see the sqlx field-cache change) the scan could
finish inside the budget and the DeadlineExceeded assertion flipped.
Use an already expired deadline instead so the outcome does not depend
on scanning speed.
collectByName skipped whole subtrees behind unexported embedded pointers
to avoid the nil-intermediate panic, but with the pointer set the old
per-row code scanned their inner tagged fields (flagEmbedRO does not
propagate into exported inner fields), so those columns were silently
discarded. Recurse into such embeddings again and resolve byName paths
with a walk that fails with ErrNotReadableValue when crossing a nil
embedded pointer ptrIndex cannot pre-allocate (unexported, or db:"-"
so collectFlat skipped it) — where the old code panicked. Slice scans
always build fresh rows, so only the single-row path can carry a
pre-set pointer.

Also gofmt orm_bench_test.go.
The old per-row getTaggedFieldValueMap ran getValueInterface on every
visited tagged field, allocating its nil pointer as a side effect even
when the column was not selected — including db:"-" tagged leaves,
tagged fields inside db:"-" or unexported embeddings, and fields whose
tag a later duplicate overwrote. The cached path only mirrored the
unwrapFields side effect (ptrIndex), so those pointers stayed nil.

Collect taggedPtrIndex during the collectByName walk (the walk visits
overwritten duplicates too; unexported tagged leaves errored in the old
code and still do at scan time, so they stay out), and run it as a
second pass in initPtrFields after ptrIndex, via the nil-safe
fieldByIndex: a path crossing an unallocated embedded pointer outside
ptrIndex is skipped, keeping the ErrNotReadableValue-not-panic posture
for columns that actually match.

Regression tests cover the db:"-" leaf, the db:"-" embedding subtree
(tagged and db:"-" leaves allocated, untagged pointer stays nil) and
the overwritten duplicate inside a db:"-" embedding. Bench unchanged
within noise (100 rows 430µs before/after).
@kevwan
kevwan force-pushed the perf/sqlx-cache-fields branch from 4db5f25 to 7a4bc86 Compare September 5, 2026 13:51
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.69892% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
core/stores/sqlx/orm.go 95.69% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@kevwan

kevwan commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for the optimization. The type-level cache is valuable: the benchmark shows roughly 2x improvement for larger result sets and about half the allocations, and it fills a gap not covered by the existing core/mapping caches.

I found two compatibility issues that should be addressed before merging:

  1. Empty result sets now fail field-count validation. In orm.go, the strict/positional count checks run before the first scanner.Next(). A query returning zero rows can now return ErrNotMatchDestination where the previous implementation returned success. Please defer row-dependent validation until after the first row, or otherwise preserve the empty-result behavior.

  2. The timeout test no longer exercises scanning. TestQueryRowsScanTimeout now uses an already-expired context and does not set up any rows, so it only tests query setup. Please keep deterministic coverage for a deadline expiring during row iteration.

There is also a behavior change worth resolving or documenting: an unselected unexported field with a non-empty db tag previously caused ErrNotReadableValue while building the tagged map; the cached implementation silently succeeds. If preserving compatibility is intended, retain that validation (with a regression test).

The caching approach itself looks worthwhile; with the above fixes I would support merging it.

…ut test

- defer strict/positional count checks until after the first row: a
  zero-row result set stays a success, like the previous per-row
  implementation whose checks never ran without a row
- retain ErrNotReadableValue for unexported fields with a non-empty db
  tag (db:"-" included): the old per-row tagged map build raised it on
  every row even when the column was not selected; regression tests
  cover both scan paths, the zero-row case and error precedence
- TestQueryRowsScanTimeout: keep the original live-deadline shape with
  100k rows (~10x margin over the 2ms budget, ~1.2x before) so the
  deadline reliably expires during row iteration
@suhuaqin

suhuaqin commented Sep 8, 2026

Copy link
Copy Markdown
Author

Thanks for the careful review — all three points are addressed in 2502aa3:

  1. Empty result sets: the strict/positional count checks are now deferred until after the first scanner.Next(), so a zero-row query returns success exactly like the previous per-row implementation, whose checks lived inside mapStructFieldsIntoSlice and never ran without a row. Covered by TestRegressZeroRowsSkipCountValidation (strict-fewer-columns and untagged-more-columns shapes, both with zero rows).

  2. Timeout test: TestQueryRowsScanTimeout is back in the original live-deadline shape — a real context deadline expiring during row iteration — instead of the already-expired-context version that only exercised query setup. The row count went from 10k to 100k: 10k rows scanned in ~2.4ms vs the 2ms budget (~1.2x, which the speedup made racable), 100k rows scan in ~19ms (~10x margin). 500 consecutive runs and 50 -race runs are green.

  3. Unexported tagged fields: compatibility retained. An unexported field with a non-empty db tag (including db:"-") fails every scanned row with ErrNotReadableValue even when no column selects it, at the same point (after the pointer side effects) and with the same precedence (strict count check first) as the old per-row map build. Covered by TestRegressUnexportedTaggedFieldErrors (slice and single-row paths, zero rows, precedence) and TestRegressUnexportedTagIgnoreFieldErrors.

The PR description was updated accordingly (see "Review follow-up").

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants