evict LRU stmt when stmt cache is full
This commit is contained in:
58
sqlite3.go
58
sqlite3.go
@@ -451,7 +451,13 @@ type SQLiteConn struct {
|
|||||||
txlock string
|
txlock string
|
||||||
funcs []*functionInfo
|
funcs []*functionInfo
|
||||||
aggregators []*aggInfo
|
aggregators []*aggInfo
|
||||||
stmtCache map[string][]*SQLiteStmt
|
// Prepared-statement cache. stmtCacheBuf is a preallocated slice of
|
||||||
|
// length stmtCacheSize holding up to stmtCacheCount live entries at
|
||||||
|
// indices [0, stmtCacheCount). Ordering is LRU-first: index 0 is the
|
||||||
|
// oldest (next to be evicted), index stmtCacheCount-1 is the most
|
||||||
|
// recently put. put at the tail is O(1) when not full; eviction shifts
|
||||||
|
// the remaining entries left by one.
|
||||||
|
stmtCacheBuf []*SQLiteStmt
|
||||||
stmtCacheSize int
|
stmtCacheSize int
|
||||||
stmtCacheCount int
|
stmtCacheCount int
|
||||||
}
|
}
|
||||||
@@ -1613,7 +1619,7 @@ func (d *SQLiteDriver) Open(dsn string) (driver.Conn, error) {
|
|||||||
// Create connection to SQLite
|
// Create connection to SQLite
|
||||||
conn := &SQLiteConn{db: db, loc: loc, txlock: txlock, stmtCacheSize: stmtCacheSize}
|
conn := &SQLiteConn{db: db, loc: loc, txlock: txlock, stmtCacheSize: stmtCacheSize}
|
||||||
if stmtCacheSize > 0 {
|
if stmtCacheSize > 0 {
|
||||||
conn.stmtCache = make(map[string][]*SQLiteStmt)
|
conn.stmtCacheBuf = make([]*SQLiteStmt, stmtCacheSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password Cipher has to be registered before authentication
|
// Password Cipher has to be registered before authentication
|
||||||
@@ -1917,46 +1923,66 @@ func (c *SQLiteConn) takeCachedStmt(query string) *SQLiteStmt {
|
|||||||
if c.db == nil {
|
if c.db == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
stmts := c.stmtCache[query]
|
// Scan from the MRU end (tail) so that a stmt put just before is
|
||||||
if len(stmts) == 0 {
|
// found immediately.
|
||||||
return nil
|
for i := c.stmtCacheCount - 1; i >= 0; i-- {
|
||||||
|
s := c.stmtCacheBuf[i]
|
||||||
|
if s.cacheKey != query {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
s := stmts[len(stmts)-1]
|
// Remove s from the buffer by shifting subsequent entries left.
|
||||||
if len(stmts) == 1 {
|
if i != c.stmtCacheCount-1 {
|
||||||
delete(c.stmtCache, query)
|
copy(c.stmtCacheBuf[i:c.stmtCacheCount-1], c.stmtCacheBuf[i+1:c.stmtCacheCount])
|
||||||
} else {
|
|
||||||
c.stmtCache[query] = stmts[:len(stmts)-1]
|
|
||||||
}
|
}
|
||||||
c.stmtCacheCount--
|
c.stmtCacheCount--
|
||||||
|
c.stmtCacheBuf[c.stmtCacheCount] = nil
|
||||||
s.closed = false
|
s.closed = false
|
||||||
s.cls = false
|
s.cls = false
|
||||||
s.t = ""
|
s.t = ""
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SQLiteConn) putCachedStmt(s *SQLiteStmt) bool {
|
func (c *SQLiteConn) putCachedStmt(s *SQLiteStmt) bool {
|
||||||
if c == nil || s == nil || s.s == nil || s.cacheKey == "" {
|
if c == nil || s == nil || s.s == nil || s.cacheKey == "" || c.stmtCacheSize <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if c.db == nil || c.stmtCacheCount >= c.stmtCacheSize {
|
if c.db == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
rv := C._sqlite3_reset_clear(s.s)
|
rv := C._sqlite3_reset_clear(s.s)
|
||||||
if rv != C.SQLITE_ROW && rv != C.SQLITE_OK && rv != C.SQLITE_DONE {
|
if rv != C.SQLITE_ROW && rv != C.SQLITE_OK && rv != C.SQLITE_DONE {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
c.stmtCache[s.cacheKey] = append(c.stmtCache[s.cacheKey], s)
|
// If full, finalize the least-recently-used entry at index 0 and
|
||||||
|
// compact the remaining entries left by one.
|
||||||
|
if c.stmtCacheCount == c.stmtCacheSize {
|
||||||
|
victim := c.stmtCacheBuf[0]
|
||||||
|
runtime.SetFinalizer(victim, nil)
|
||||||
|
if victim.s != nil {
|
||||||
|
C.sqlite3_finalize(victim.s)
|
||||||
|
victim.s = nil
|
||||||
|
}
|
||||||
|
victim.c = nil
|
||||||
|
victim.closed = true
|
||||||
|
copy(c.stmtCacheBuf[0:c.stmtCacheCount-1], c.stmtCacheBuf[1:c.stmtCacheCount])
|
||||||
|
c.stmtCacheCount--
|
||||||
|
}
|
||||||
|
// Append at the MRU tail.
|
||||||
|
c.stmtCacheBuf[c.stmtCacheCount] = s
|
||||||
c.stmtCacheCount++
|
c.stmtCacheCount++
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SQLiteConn) closeCachedStmtsLocked() {
|
func (c *SQLiteConn) closeCachedStmtsLocked() {
|
||||||
for key, stmts := range c.stmtCache {
|
for i := 0; i < c.stmtCacheCount; i++ {
|
||||||
for _, s := range stmts {
|
s := c.stmtCacheBuf[i]
|
||||||
|
c.stmtCacheBuf[i] = nil
|
||||||
if s == nil || s.s == nil {
|
if s == nil || s.s == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1965,8 +1991,6 @@ func (c *SQLiteConn) closeCachedStmtsLocked() {
|
|||||||
s.s = nil
|
s.s = nil
|
||||||
s.c = nil
|
s.c = nil
|
||||||
}
|
}
|
||||||
delete(c.stmtCache, key)
|
|
||||||
}
|
|
||||||
c.stmtCacheCount = 0
|
c.stmtCacheCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
sqlite3_stmt_cache_bench_test.go
Normal file
88
sqlite3_stmt_cache_bench_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (C) 2019 Yasuhiro Matsumoto <mattn.jp@gmail.com>.
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build cgo
|
||||||
|
// +build cgo
|
||||||
|
|
||||||
|
package sqlite3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkStmtCache measures the stmt cache hit / miss / eviction paths by
|
||||||
|
// cycling through a fixed set of queries under various cache sizes. It is
|
||||||
|
// intended for comparing cache behavior changes, not for absolute numbers.
|
||||||
|
func BenchmarkStmtCache(b *testing.B) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cacheSize int
|
||||||
|
keyCount int
|
||||||
|
}{
|
||||||
|
{"off", 0, 1}, // baseline: no cache
|
||||||
|
{"size4_keys1_hit", 4, 1}, // trivial hit path
|
||||||
|
{"size4_keys4_hit", 4, 4}, // all queries fit, always hit
|
||||||
|
{"size4_keys8_evict", 4, 8}, // working set > cache: miss + eviction
|
||||||
|
{"size16_keys8_hit", 16, 8}, // all queries fit in larger cache
|
||||||
|
{"size16_keys32_evict", 16, 32}, // working set >> cache
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
b.Run(tc.name, func(b *testing.B) {
|
||||||
|
dsn := ":memory:"
|
||||||
|
if tc.cacheSize > 0 {
|
||||||
|
dsn = fmt.Sprintf(":memory:?_stmt_cache_size=%d", tc.cacheSize)
|
||||||
|
}
|
||||||
|
d := SQLiteDriver{}
|
||||||
|
conn, err := d.Open(dsn)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
c := conn.(*SQLiteConn)
|
||||||
|
|
||||||
|
queries := make([]string, tc.keyCount)
|
||||||
|
for i := range queries {
|
||||||
|
// Distinct literal forces a distinct prepared statement.
|
||||||
|
queries[i] = fmt.Sprintf("SELECT %d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
// Warm up: exercise each query at least once so the cache (if any)
|
||||||
|
// reaches steady state before timing begins.
|
||||||
|
for _, q := range queries {
|
||||||
|
rows, err := c.query(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
drainRows(b, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
q := queries[i%len(queries)]
|
||||||
|
rows, err := c.query(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
drainRows(b, rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drainRows(b *testing.B, rows driver.Rows) {
|
||||||
|
b.Helper()
|
||||||
|
dest := make([]driver.Value, len(rows.Columns()))
|
||||||
|
for {
|
||||||
|
if err := rows.Next(dest); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
152
sqlite3_stmt_cache_test.go
Normal file
152
sqlite3_stmt_cache_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// Copyright (C) 2019 Yasuhiro Matsumoto <mattn.jp@gmail.com>.
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build cgo
|
||||||
|
// +build cgo
|
||||||
|
|
||||||
|
package sqlite3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStmtCacheLRUEviction verifies that when the prepared-statement cache is
|
||||||
|
// full, the least-recently-used entry is evicted to make room for a new one.
|
||||||
|
// Without eviction, the first N queries to enter the cache would squat on
|
||||||
|
// every slot forever and any subsequently-prepared query (even a hot one)
|
||||||
|
// would never benefit from caching.
|
||||||
|
func TestStmtCacheLRUEviction(t *testing.T) {
|
||||||
|
d := SQLiteDriver{}
|
||||||
|
conn, err := d.Open(":memory:?_stmt_cache_size=2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
c := conn.(*SQLiteConn)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
prepareAndClose := func(q string) {
|
||||||
|
t.Helper()
|
||||||
|
stmt, err := c.prepareWithCache(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareWithCache(%q): %v", q, err)
|
||||||
|
}
|
||||||
|
if err := stmt.Close(); err != nil {
|
||||||
|
t.Fatalf("Close(%q): %v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q1 := "SELECT 1"
|
||||||
|
q2 := "SELECT 2"
|
||||||
|
q3 := "SELECT 3"
|
||||||
|
|
||||||
|
// Fill the cache with q1 and q2.
|
||||||
|
prepareAndClose(q1)
|
||||||
|
prepareAndClose(q2)
|
||||||
|
if got, want := c.stmtCacheCount, 2; got != want {
|
||||||
|
t.Fatalf("after filling: stmtCacheCount = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if cacheCount(c, q1) != 1 || cacheCount(c, q2) != 1 {
|
||||||
|
t.Fatalf("after filling: expected q1 and q2 cached, got %#v", cacheKeys(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert q3. q1 is the oldest entry and should be evicted.
|
||||||
|
prepareAndClose(q3)
|
||||||
|
if got, want := c.stmtCacheCount, 2; got != want {
|
||||||
|
t.Fatalf("after q3: stmtCacheCount = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if cacheCount(c, q1) != 0 {
|
||||||
|
t.Fatalf("after q3: q1 should have been evicted, cache=%#v", cacheKeys(c))
|
||||||
|
}
|
||||||
|
if cacheCount(c, q2) != 1 || cacheCount(c, q3) != 1 {
|
||||||
|
t.Fatalf("after q3: expected q2 and q3 cached, got %#v", cacheKeys(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touching q2 should make q3 the oldest (the entry at buf[0]).
|
||||||
|
prepareAndClose(q2)
|
||||||
|
if c.stmtCacheCount == 0 || c.stmtCacheBuf[0].cacheKey != q3 {
|
||||||
|
var head string
|
||||||
|
if c.stmtCacheCount > 0 {
|
||||||
|
head = c.stmtCacheBuf[0].cacheKey
|
||||||
|
}
|
||||||
|
t.Fatalf("after touching q2: expected q3 at buf[0] (LRU), got %q", head)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert q1 again. Now q3 should be evicted (q2 is newer).
|
||||||
|
prepareAndClose(q1)
|
||||||
|
if cacheCount(c, q3) != 0 {
|
||||||
|
t.Fatalf("after reinserting q1: q3 should have been evicted, cache=%#v", cacheKeys(c))
|
||||||
|
}
|
||||||
|
if cacheCount(c, q1) != 1 || cacheCount(c, q2) != 1 {
|
||||||
|
t.Fatalf("after reinserting q1: expected q1 and q2 cached, got %#v", cacheKeys(c))
|
||||||
|
}
|
||||||
|
if got, want := c.stmtCacheCount, 2; got != want {
|
||||||
|
t.Fatalf("after reinserting q1: stmtCacheCount = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity-check: no dangling entries past stmtCacheCount.
|
||||||
|
for i := c.stmtCacheCount; i < len(c.stmtCacheBuf); i++ {
|
||||||
|
if c.stmtCacheBuf[i] != nil {
|
||||||
|
t.Fatalf("stmtCacheBuf[%d] = %p, expected nil tail slot", i, c.stmtCacheBuf[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStmtCacheReuseReturnsSameHandle verifies that a cached prepare reuses
|
||||||
|
// the underlying sqlite3_stmt rather than preparing a fresh one.
|
||||||
|
func TestStmtCacheReuseReturnsSameHandle(t *testing.T) {
|
||||||
|
d := SQLiteDriver{}
|
||||||
|
conn, err := d.Open(":memory:?_stmt_cache_size=4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
c := conn.(*SQLiteConn)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const q = "SELECT 42"
|
||||||
|
stmt1, err := c.prepareWithCache(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h1 := stmt1.(*SQLiteStmt).s
|
||||||
|
if err := stmt1.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt2, err := c.prepareWithCache(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h2 := stmt2.(*SQLiteStmt).s
|
||||||
|
if err := stmt2.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Fatalf("expected cached prepare to reuse sqlite3_stmt handle, got %p vs %p", h1, h2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheKeys(c *SQLiteConn) map[string]int {
|
||||||
|
out := make(map[string]int)
|
||||||
|
for i := 0; i < c.stmtCacheCount; i++ {
|
||||||
|
out[c.stmtCacheBuf[i].cacheKey]++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheCount(c *SQLiteConn, q string) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < c.stmtCacheCount; i++ {
|
||||||
|
if c.stmtCacheBuf[i].cacheKey == q {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user