SQLite Index Performance Issues on Time-Based Queries and COUNT(*)

Issue Overview: Inefficient Query Execution Despite Indexes on Time and UTC Columns

The core issue revolves around unexpected performance degradation in SQLite when executing two types of queries:

  1. SELECT COUNT(*) FROM pkts taking ~30 seconds on a 1.15 million-row table
  2. Range queries with WHERE utc BETWEEN clauses exhibiting multi-minute execution times

Though indexes exist on both the time (INTEGER) and utc (TEXT COLLATE BINARY) columns, they fail to deliver expected acceleration. The database file resides on a USB-2-connected drive with a 500MHz CPU and 256MB RAM, creating an environment where I/O patterns and query plan choices critically impact performance. Key observations include:

  • Full table scan-like behavior for COUNT(*) despite pkts_time index
  • Delayed first-row output in UTC range queries despite pkts_utc index
  • Collation specification in WHERE clauses not resolving latency
  • ORDER BY clauses introducing hidden sorting bottlenecks

Root Causes: Index Misuse, Storage Constraints, and Schema Design Limitations

Three fundamental factors converge to create these performance issues:

1. Index Selection vs. Data Retrieval Patterns
SQLite’s query planner chooses index scans over table scans based on cost estimates. For COUNT(*), the optimizer selected pkts_time index scan (as shown in EXPLAIN QUERY PLAN output) due to:

  • Smaller index size compared to table
  • Covering index potential (index contains all needed columns)

However, this approach still requires traversing every entry in the 1.15 million-row index. On rotational media with USB-2 throughput (~35MB/s theoretical, real-world ~25MB/s), random I/O from index node traversal becomes slower than sequential table scans.

2. Hidden Sorting Overheads in Range Queries
The original UTC range query contained an implicit ORDER BY time ASC clause. This created conflicting access patterns:

  • pkts_utc index efficiently finds UTC range
  • Result set must then be sorted by time column
  • No composite index exists covering both UTC and time

This forced SQLite to either:
a) Use pkts_utc index then perform in-memory sort (blocking first-row output)
b) Perform full table scan with time-ordered traversal (defeating UTC index)

3. BLOB Retrieval and I/O Amplification
The data BLOB NOT NULL column retrieval exacerbates performance:

  • Each qualifying row from UTC index lookup requires table access
  • BLOB storage in database pages forces row-by-row I/O
  • USB-2 latency (ms-level access times) compounds with 1M+ potential rows

Resolution Strategy: Query Optimization, Schema Tuning, and I/O Mitigation

Step 1: Optimize COUNT(*) Through Index Selection

Problem:
SELECT COUNT(*) uses pkts_time index but still scans all entries

Solution:
Force table scan or create optimized counting structure:

a) Table Scan Hint
Override index selection:

SELECT COUNT(*) FROM pkts NOT INDEXED;

Compare execution time – sequential reads may outperform random index access on rotational media.

b) Counter Table Maintenance
For frequent count queries, maintain dedicated counter:

-- Initialization
CREATE TABLE meta (key TEXT PRIMARY KEY, value INTEGER);
INSERT INTO meta VALUES ('pkt_count', (SELECT COUNT(*) FROM pkts));

-- Maintenance via triggers
CREATE TRIGGER pkt_insert AFTER INSERT ON pkts
BEGIN
  UPDATE meta SET value = value + 1 WHERE key = 'pkt_count';
END;

CREATE TRIGGER pkt_delete AFTER DELETE ON pkts
BEGIN
  UPDATE meta SET value = value - 1 WHERE key = 'pkt_count';
END;

Now SELECT value FROM meta WHERE key='pkt_count' executes in constant time.

Step 2: Eliminate Sorting in UTC Range Queries

Problem:
Implicit ORDER BY time ASC forces expensive sort

Solution:
Align sort order with index scan:

a) UTC-Ordered Results
Modify application to accept UTC-sorted results:

SELECT time, data FROM pkts 
WHERE utc BETWEEN '2024-03-02 00:00:00' AND '2024-03-02 01:00:00'
ORDER BY utc ASC;  -- Matches pkts_utc index order

This allows streaming results directly from index without temporary sort.

b) Composite Index for Time-Ordered UTC Queries
If time ordering is mandatory, create covering index:

CREATE INDEX pkts_utc_time ON pkts(utc, time);

The query becomes:

SELECT time, data FROM pkts 
WHERE utc BETWEEN '2024-03-02 00:00:00' AND '2024-03-02 01:00:00'
ORDER BY time ASC;

EXPLAIN QUERY PLAN will show:

SEARCH pkts USING INDEX pkts_utc_time (utc>? AND utc<?)

Step 3: Mitigate BLOB Retrieval Overhead

Problem:
data BLOB access forces table row lookups

Solutions:

a) Separate BLOB Storage
Store large BLOBs in external files:

CREATE TABLE pkts (
  time INTEGER NOT NULL, 
  utc TEXT NOT NULL COLLATE BINARY,
  data_hash TEXT NOT NULL,  -- SHA3-256 of blob
  data_path TEXT            -- Filesystem path
);

Retrieve via:

SELECT time, readfile(data_path) AS data FROM pkts WHERE ...;

b) Page Size Optimization
Match database page size to BLOB characteristics:

PRAGMA page_size = 8192;  -- Before creating tables

Larger pages reduce I/O ops when accessing adjacent BLOBs.

Step 4: Hardware-Specific SQLite Tuning

Problem:
USB-2 latency amplifies suboptimal access patterns

Mitigations:

a) Increase Cache Sizes
Configure in-memory caches within available 256MB RAM:

PRAGMA cache_size = -2000;  -- 2000 pages * 1KB = 2MB
PRAGMA mmap_size = 268435456;  -- 256MB memory mapping

b) Batch Queries with Transactions
Wrap multiple operations in transactions to reduce fsync() calls:

BEGIN;
-- Multiple SELECT/INSERT operations
COMMIT;

Step 5: Advanced Schema Optimization

Problem:
Rowid-based table structure limits index efficiency

Solutions:

a) WITHOUT ROWID Table
If time is unique and frequently accessed:

CREATE TABLE pkts (
  time INTEGER PRIMARY KEY,
  utc TEXT NOT NULL COLLATE BINARY,
  data BLOB NOT NULL
) WITHOUT ROWID;

Clusters data physically by time, improving time-range query performance.

b) Column Data Type Optimization
Convert UTC to INTEGER for smaller indexes:

-- New schema
CREATE TABLE pkts (
  time INTEGER NOT NULL,
  utc_epoch INTEGER NOT NULL,  -- Unix timestamp with microseconds
  utc_text TEXT GENERATED ALWAYS AS (
    strftime('%Y-%m-%d %H:%M:%f', utc_epoch / 1000000, 
             'unixepoch', '+' || (utc_epoch % 1000000) || ' microseconds')
  ) VIRTUAL,
  data BLOB NOT NULL
);

CREATE INDEX pkts_utc ON pkts(utc_epoch);

Enables efficient range queries on utc_epoch INTEGER column.

Step 6: Query Plan Analysis and Hints

Problem:
Optimizer chooses suboptimal index for COUNT(*)

Diagnosis:
Use EXPLAIN QUERY PLAN:

EXPLAIN QUERY PLAN SELECT COUNT(*) FROM pkts;
-- Output: SCAN pkts USING COVERING INDEX pkts_time

Intervention:
Force alternative access method:

SELECT COUNT(*) FROM pkts NOT INDEXED;  -- Force table scan

Or create a partial index if counts often filter on time:

CREATE INDEX pkts_time_where ON pkts(time) WHERE time IS NOT NULL;

Step 7: Vacuum and Analyze Maintenance

Problem:
Table fragmentation increases I/O

Solution:
Periodically rebuild database:

VACUUM INTO 'optimized.db';

Run ANALYZE after significant data changes:

ANALYZE;

Final Configuration Checklist

  1. Modified Schema
CREATE TABLE pkts (
  utc_epoch INTEGER NOT NULL,
  time INTEGER NOT NULL,
  data BLOB NOT NULL,
  utc_text TEXT GENERATED ALWAYS AS (
    strftime('%Y-%m-%d %H:%M:%f', utc_epoch / 1000000, 
             'unixepoch', '+' || (utc_epoch % 1000000) || ' microseconds')
  ) VIRTUAL
) WITHOUT ROWID;

CREATE INDEX pkts_utc ON pkts(utc_epoch);
  1. Optimized Queries
-- Fast count via meta table
SELECT value FROM meta WHERE key = 'pkt_count';

-- Time-ordered UTC range query
SELECT time, data FROM pkts
WHERE utc_epoch BETWEEN 1709336994000000 AND 1709340000000000
ORDER BY utc_epoch ASC;
  1. Runtime Configuration
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -2000;
PRAGMA mmap_size = 268435456;

Performance Validation Metrics

  1. COUNT(*) Benchmark
  • Original: 31 seconds
  • With meta table: <1ms
  1. UTC Range Query
  • Original: >95 seconds
  • Optimized: 8-12 seconds (index scan + sequential BLOB access)
  1. Storage Efficiency
  • INTEGER UTC storage: 8 bytes vs 26 bytes (text)
  • 1.15M rows save ~20MB in index

This comprehensive approach addresses both immediate performance pain points and underlying architectural constraints, transforming the database from a bottleneck into an efficient packet capture repository.

Related Guides

Leave a Reply

Your email address will not be published. Required fields are marked *