↓快轉到主要內容
  1. 教學文章/

Streamlit SQL 連線實戰:st.connection、查詢快取 TTL 與交易邊界

·7 分鐘· loading · loading · ·
Python Streamlit SQL SQLConnection Cache Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 138: 本文

一. 前言:按了重新整理,資料卻沒變?
#

拍拍君今天遇到一個很像靈異事件的問題。 SQL 查得到新資料,Streamlit 表格卻一直顯示舊值。 重跑 App、切換頁面,都沒有用。

兇手不是資料庫,也不是瀏覽器。 是我們只記得「連線可以共用」,忘記「查詢結果也可能被快取」。

今天不做另一個 CRUD 後台。 我們用很小的資料集拆清楚三條邊界:

  • st.connection 管連線資源。
  • conn.query 管讀取結果與快取。
  • 短生命週期 Session 管交易。

UI 可以簡單,資料新鮮度不能靠猜。

二. 安裝與可重現的專案
#

uv init pypy-sql-connection
cd pypy-sql-connection
uv add streamlit sqlalchemy pandas
uv add --dev pytest
mkdir -p .streamlit

本篇使用 SQLAlchemy 2.x 寫法。 SQLite 已包含在 Python 裡,不必另外啟動伺服器。 需要 PostgreSQL 時,還要裝對應 driver,不能只改 URL。

專案只有四個 Python 檔案:

pypy-sql-connection/
├── app.py
├── seed.py
├── queries.py
├── test_queries.py
└── .streamlit/secrets.toml

這是查詢與交易契約的範例,不是多人產品的完整架構。 套件版本請交給 uv.lock 固定;別把「今天最新」當作可重現版本。

三. Named Connection:名字要對上 Secrets
#

建立 .streamlit/secrets.toml:

[connections.pypy]
url = "sqlite:///pypy.db"

程式使用 st.connection("pypy", type="sql")。 pypy 對應 [connections.pypy],不是資料表名稱。 自訂名稱要指定 type="sql";不要期待名稱自動猜中類型。

SQLite 的相對路徑以啟動程式的工作目錄為準。 因此以下指令都在專案根目錄執行。

真實資料庫的 URL 可能包含密碼。 請忽略實際 Secrets 檔,只提交不含憑證的範本:

.streamlit/secrets.toml
*.db
*.db-journal

部署端另外設定 Secrets;不要印出完整連線 URL 除錯。 如果同時用 kwargs 與 Secrets 設定同一欄位,要明確知道覆蓋來源。 這一點不是格式問題,是「你究竟連到哪個資料庫」的問題。

完整部署流程可接著看 Streamlit 部署與 Secrets。

四. 先做一份不會重跑就清空的資料
#

建立 seed.py,只在初始化時執行:

from sqlalchemy import create_engine, text

engine = create_engine("sqlite:///pypy.db")
with engine.begin() as db:
    db.execute(text("""
        CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY,
            owner TEXT NOT NULL,
            status TEXT NOT NULL
        )
    """))
    db.execute(text("""
        CREATE TABLE IF NOT EXISTS audit (
            task_id INTEGER NOT NULL,
            new_status TEXT NOT NULL
        )
    """))
    count = db.scalar(text("SELECT COUNT(*) FROM tasks"))
    if count == 0:
        db.execute(
            text("INSERT INTO tasks VALUES (:id, :owner, :status)"),
            [
                {"id": 1, "owner": "拍拍君", "status": "review"},
                {"id": 2, "owner": "拍拍醬", "status": "done"},
            ],
        )
uv run python seed.py

不要把重設資料庫塞進 App 的頂層。 Streamlit rerun 是正常互動,不是重新安裝應用程式。 正式 schema 變更仍應使用 migration,不靠 CREATE TABLE IF NOT EXISTS 補洞。

五. 分清楚兩種 TTL
#

下面兩個 ttl 看起來相同,責任卻不同:

import streamlit as st

conn = st.connection("pypy", type="sql", ttl=3600)
rows = conn.query("SELECT id, status FROM tasks", ttl=30)
設定位置 控制的對象 不代表什麼
st.connection(..., ttl=3600) 快取的連線物件 結果每小時才更新
conn.query(..., ttl=30) 查詢結果 每 30 秒主動輪詢

TTL 到期不是背景計時器。 需要下一次讀取,才有機會重新執行 SQL。 如果畫面沒有 rerun,它不會自己長出新資料。

這裡刻意給查詢明確的 30 秒期限。 預設沒有期限的讀取,很容易把「快」變成「永遠看舊資料」。 官方 API 的快取語意可對照 SQLConnection.query。

六. 參數化:值不要拼到 SQL 裡
#

建立 queries.py,先放共用查詢:

from sqlalchemy import text

TASK_SQL = """
SELECT id, owner, status
FROM tasks
WHERE owner = :owner
ORDER BY id
LIMIT :row_limit
"""

def read_tasks(conn, owner: str, *, fresh: bool = False):
    return conn.query(
        TASK_SQL,
        params={"owner": owner, "row_limit": 20},
        ttl=0 if fresh else 30,
        show_spinner=False,
    )

owner 是資料值,交給 bind parameter。 這樣輸入裡有引號,也不會變成另一段 SQL。 不同查詢參數也應產生各自的快取結果,稍後會測這件事。

參數化不會替你處理 table name、排序欄位或 SQL 關鍵字。 動態排序應先映射固定白名單,不能把使用者文字當識別字。 LIMIT 也不能省:快取不是無限制下載資料的理由。

本篇用 SQLite 的 SQLAlchemy named binds。 換資料庫時,仍須確認 driver 與 SQL dialect 的支援範圍。

七. 最小 App:讓新鮮度成為明確選項
#

建立 app.py:

import streamlit as st
from queries import read_tasks

st.title("拍拍君 SQL 連線觀察室")
conn = st.connection("pypy", type="sql")
owner = st.selectbox("示範資料擁有者", ["拍拍君", "拍拍醬"])
fresh = st.checkbox("即時讀取,不沿用查詢快取", value=False)
st.button("重新讀取")

rows = read_tasks(conn, owner, fresh=fresh)
st.caption("即時模式" if fresh else "查詢快取最長 30 秒")
st.dataframe(rows, hide_index=True)
uv run streamlit run app.py

按鈕會觸發 rerun,但不承諾清掉結果快取。 在 30 秒內按下去,仍可能看到同一份資料。 勾選即時模式,才是本範例的明確 fresh-read 契約。

ttl=0 會讓這條讀取不沿用舊結果,但不是清空所有別人的快取。 退回 30 秒模式時,原本的快取仍可能在有效期限內。 如果你的產品要求寫入後立刻一致,請直接走 fresh read,別只呼叫 rerun。

八. 寫入與交易:不要用 query 假裝更新
#

conn.query 的介面是讀取便利工具,不是交易入口。 需要寫入時,使用短 Session,並在同一個交易內完成相關操作。 在 queries.py 加入下面函式:

def mark_done(conn, task_id: int) -> None:
    with conn.session as session:
        with session.begin():
            result = session.execute(
                text("UPDATE tasks SET status = :s WHERE id = :id"),
                {"s": "done", "id": task_id},
            )
            if result.rowcount != 1:
                raise LookupError("找不到唯一任務")
            session.execute(
                text("INSERT INTO audit VALUES (:id, :s)"),
                {"id": task_id, "s": "done"},
            )

正常離開 session.begin() 時提交;例外則 rollback。 任務更新與 audit 寫入一起成功,才算完成。 詳細語意可看 SQLAlchemy 交易文件。

這是示範交易原子性,不是可重複執行的事件處理器。 同一任務重複呼叫會多一筆 audit;正式系統要定義冪等鍵或狀態轉移規則。 也不要把 Session 放進 session_state,等使用者按鈕等十分鐘。

九. Commit 不等於快取失效
#

資料庫提交後,舊的查詢快取不會因為資料表變更自動收到通知。 因此讀取政策必須獨立設計:

  • 報表允許 30 秒延遲:保留短 TTL,畫面清楚註記。
  • 寫入後確認結果:用即時讀取,不沿用舊結果。
  • 多處資料一起更新:在服務層設計一致的失效策略。

conn.reset() 主要處理連線重建,不是跨使用者的資料失效協定。 不要讓「重連成功」偷偷變成「所有列表一定新鮮」的產品承諾。

st.cache_data.clear() 是粗粒度操作。 共用 App 使用它時,可能影響其他查詢與使用者,不能當作每次提交的預設動作。 資料規模大時,應考慮自有可控快取層,而不是不斷製造不同 key。

十. 可執行的行為測試
#

建立 test_queries.py。 以下用暫存 SQLite 與固定資料,避免碰到開發資料庫。

from uuid import uuid4
import pytest
import streamlit as st
from sqlalchemy import text
from queries import mark_done, read_tasks

@pytest.fixture
def conn(tmp_path):
    db_path = tmp_path / "test.db"
    conn = st.connection(
        f"test_{uuid4().hex}", type="sql", url=f"sqlite:///{db_path}"
    )
    with conn.engine.begin() as db:
        db.execute(text("CREATE TABLE tasks (id INTEGER PRIMARY KEY, owner TEXT, status TEXT)"))
        db.execute(text("CREATE TABLE audit (task_id INTEGER, new_status TEXT)"))
        db.execute(text("INSERT INTO tasks VALUES (1, '拍拍君', 'review'), (2, '拍拍醬', 'review')"))
    yield conn
    st.cache_data.clear()
    st.cache_resource.clear()
    conn.engine.dispose()

def test_params_and_freshness(conn):
    first = read_tasks(conn, "拍拍君")
    assert first["id"].tolist() == [1]
    assert read_tasks(conn, "拍拍醬")["id"].tolist() == [2]
    assert read_tasks(conn, "' OR 1=1 --").empty
    mark_done(conn, 1)
    assert read_tasks(conn, "拍拍君")["status"].tolist() == ["review"]
    assert read_tasks(conn, "拍拍君", fresh=True)["status"].tolist() == ["done"]

def test_rollback_if_audit_fails(conn):
    with conn.engine.begin() as db:
        db.execute(text("DROP TABLE audit"))
    from sqlalchemy.exc import SQLAlchemyError
    with pytest.raises(SQLAlchemyError):
        mark_done(conn, 1)
    assert read_tasks(conn, "拍拍君", fresh=True)["status"].tolist() == ["review"]
uv run pytest -q

第一個測試直接重現「commit 了,但快取仍然舊」的情境。 第二個讓 audit 寫入失敗,確認任務更新也一起撤回。 測試裡清除全域快取是隔離測試資料,不是正式 App 的操作建議。 CLI 模式出現沒有 ScriptRunContext 的提示,不等於資料庫測試失敗。

十一. 權限、連線失效與多人使用
#

範例 selectbox 只是在切換示範資料,不是授權。 正式系統的 owner 或 tenant 必須從可信登入身分取得。 篩選條件有參數化,不表示使用者有權看那筆資料。

一般 SQLConnection 與查詢快取可能在同一個 App 行程內共用。 不要把個人權限只藏在某個 Session 變數裡,卻讓查詢快取忽略它。 所有影響可見資料的可信條件,都應進入查詢與快取契約。 更嚴格的隔離需求,可交給資料庫角色、row-level security 或服務 API。

連線偶發失效與權限錯誤,也要分開處理。 前者可能需要 conn.reset() 後重讀;後者需要修正授權,不該盲目重試。 寫入失敗更不能直接無限重送:是否已提交、是否冪等,都要先釐清。

結語:快取很香,但契約要先寫清楚
#

拍拍君最想留下的不是某一條 API,而是這個分工: 連線可以長壽,交易要短,結果的新鮮度要說得清楚。

先測參數不串資料、快取不假裝即時、失敗能 rollback。 這三件事做好,才比較值得把查詢面板交給別人每天使用。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 138: 本文

相關文章

Streamlit Custom Components:雙向事件、狀態同步、打包與測試
·8 分鐘· loading · loading
Python Streamlit Custom Components JavaScript State Testing Packaging
Streamlit 效能診斷:Rerun、Cache Hit、記憶體與 Latency Profiling
·8 分鐘· loading · loading
Python Streamlit Performance Profiling Cache Latency Memory
Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session State Ci
Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis
Python asyncio.Queue 實戰:背壓、Graceful Shutdown 與不丟資料測試
·8 分鐘· loading · loading
Python Asyncio Queue Backpressure Graceful Shutdown Concurrency Testing
DuckDB MERGE 實戰:增量匯入、去重與可重跑 ETL
·6 分鐘· loading · loading
Python DuckDB SQL Merge ETL Data-Engineering Idempotency