做过桌面工具的朋友,多少都踩过这个坑——程序跑着跑着出了问题,你打开一看,日志?没有。数据库记录?空的。只剩一个报错弹窗,连个回溯的线索都没给你留。
这不是代码写得烂,是架构设计漏了一环。
日志和数据库,本质上是两种不同维度的记录手段。 文件日志是时序流水账,适合排查"什么时间发生了什么";数据库则是结构化存档,适合做统计、筛选、分析。两者不是竞争关系,而是互补的——就像监控录像和案件档案,缺一不可。
今天咱们就用 Tkinter 搭一个真实可用的桌面应用,把这两套机制整合进去,做成一个操作行为双轨记录系统。用户在界面上的每一步操作,既写进 .log 文件,也存进 SQLite 数据库,随时可查、可导出、可分析。
文章涵盖:
logging 模块的进阶配置(不只是 basicConfig)代码全部可运行,Windows 环境验证过。
别急着写代码。先把脑子里的结构理清楚,后面写起来才不会乱。

三层结构:界面层触发事件,核心层双写,展示层读取查询。干净,职责清晰,改哪层不影响另外两层。
Python 标准库全家桶,不需要额外安装第三方包:
python# 用到的模块清单
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import logging
import sqlite3
import threading
import os
import csv
from datetime import datetime
SQLite 是 Python 内置的,logging 也是,Tkinter 在 Windows 下随 Python 一起装好了。零依赖,拿来就能跑。
很多人用 logging 只会写这一行:
pythonlogging.basicConfig(filename='app.log', level=logging.DEBUG)
能用,但太粗糙了。生产环境里这样搞,日志文件会无限增长,出了问题翻起来像大海捞针。
咱们来配一个按日期滚动、带格式、分级别的文件日志器:
pythonimport logging
from logging.handlers import TimedRotatingFileHandler
import os
def setup_file_logger(log_dir="logs"):
"""
配置文件日志器
- 按天滚动,保留最近 7 天
- 同时输出到控制台(开发调试用)
"""
os.makedirs(log_dir, exist_ok=True)
logger = logging.getLogger("AppLogger")
logger.setLevel(logging.DEBUG)
# 避免重复添加 handler(Tkinter 应用可能多次初始化)
if logger.handlers:
return logger
log_path = os.path.join(log_dir, "app.log")
# 按天滚动,保留 7 天
file_handler = TimedRotatingFileHandler(
filename=log_path,
when="midnight", # 每天零点滚动
interval=1,
backupCount=7,
encoding="utf-8"
)
file_handler.setLevel(logging.DEBUG)
# 格式:时间 | 级别 | 模块 | 消息
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)-8s | %(module)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
file_handler.setFormatter(formatter)
# 控制台 handler(只输出 WARNING 以上)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)
console_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
TimedRotatingFileHandler 是个被严重低估的工具——它会在每天零点自动把旧日志重命名为 app.log.2026-03-16,新的写进 app.log,完全不需要你手动管理。backupCount=7 保留最近 7 天,超出的自动删掉,磁盘空间稳稳的。
文件日志适合人眼阅读,但你要是想查"过去一周内,用户点击'导出'按钮超过 3 次的记录",用 grep 翻日志文件……算了,还是用 SQL 吧。
pythonimport sqlite3
import threading
from datetime import datetime
class DBLogger:
"""
SQLite 日志记录器
线程安全版本——Tkinter 多线程操作时不会写坏数据库
"""
def __init__(self, db_path="logs/app_logs.db"):
self.db_path = db_path
self._lock = threading.Lock() # 关键:写入锁
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self._init_db()
def _init_db(self):
"""建表,如果不存在的话"""
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
module TEXT,
action TEXT NOT NULL,
detail TEXT,
user TEXT DEFAULT 'default',
duration_ms INTEGER DEFAULT 0
)
""")
# 给常用查询字段加索引,数据量大了之后查询不卡
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_timestamp
ON operation_logs(timestamp)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_level
ON operation_logs(level)
""")
conn.commit()
def write(self, level, action, detail="", module="", duration_ms=0):
"""
写入一条日志记录
用锁保证多线程安全
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
with self._lock:
try:
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
INSERT INTO operation_logs
(timestamp, level, module, action, detail, duration_ms)
VALUES (?, ?, ?, ?, ?, ?)
""", (timestamp, level, module, action, detail, duration_ms))
conn.commit()
except sqlite3.Error as e:
# 数据库写失败不能让主程序崩,静默记录到文件日志
logging.getLogger("AppLogger").error(
f"DB write failed: {e} | action={action}"
)
def query(self, level=None, keyword=None, limit=200):
"""
查询日志记录
支持按级别过滤、关键词搜索
"""
sql = "SELECT * FROM operation_logs WHERE 1=1"
params = []
if level and level != "ALL":
sql += " AND level = ?"
params.append(level)
if keyword:
sql += " AND (action LIKE ? OR detail LIKE ?)"
params.extend([f"%{keyword}%", f"%{keyword}%"])
sql += " ORDER BY id DESC LIMIT ?"
params.append(limit)
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row # 让结果支持列名访问
cursor = conn.execute(sql, params)
return cursor.fetchall()
这里有个细节值得说一下:_lock = threading.Lock()。Tkinter 的事件循环跑在主线程,但如果你在后台线程做耗时操作(比如批量导入数据),同时触发日志写入,SQLite 的默认配置下会抛 database is locked 错误。加锁之后,写入操作串行化,稳得很。
两个日志器单独用没问题,但每次写日志都要调两次,代码会散。封装一个 LogManager,对外只暴露一个接口:
pythonclass LogManager:
"""
双轨日志管理器
一次调用,同时写文件 + 数据库
"""
def __init__(self):
self.file_logger = setup_file_logger()
self.db_logger = DBLogger()
self._module = "App"
def set_module(self, module_name):
self._module = module_name
return self # 支持链式调用
def _write(self, level, action, detail="", duration_ms=0):
# 写文件日志
log_msg = f"[{action}] {detail}" if detail else f"[{action}]"
getattr(self.file_logger, level.lower())(log_msg)
# 写数据库
self.db_logger.write(
level=level,
action=action,
detail=detail,
module=self._module,
duration_ms=duration_ms
)
def info(self, action, detail="", duration_ms=0):
self._write("INFO", action, detail, duration_ms)
def warning(self, action, detail="", duration_ms=0):
self._write("WARNING", action, detail, duration_ms)
def error(self, action, detail="", duration_ms=0):
self._write("ERROR", action, detail, duration_ms)
def debug(self, action, detail="", duration_ms=0):
self._write("DEBUG", action, detail, duration_ms)
def query(self, **kwargs):
"""透传给 DBLogger 的查询接口"""
return self.db_logger.query(**kwargs)
调用方式就变成这样,清爽:
pythonlog = LogManager()
log.info("用户登录", "用户 admin 登录成功")
log.warning("文件导入", "文件格式不标准,已自动修正")
log.error("数据库连接", "连接超时,重试第 2 次")
现在把界面搭起来。主窗口分两个区域:上方是操作面板(模拟用户的各种操作),下方是实时日志流。
pythonclass MainApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("操作日志双轨记录系统")
self.geometry("900x650")
self.resizable(True, True)
# 初始化日志管理器
self.log = LogManager()
self.log.set_module("MainApp")
self._build_ui()
self.log.info("系统启动", "主窗口初始化完成")
def _build_ui(self):
# ── 顶部操作面板 ──
op_frame = ttk.LabelFrame(self, text="操作面板", padding=10)
op_frame.pack(fill="x", padx=10, pady=(10, 5))
# 输入框 + 标签
ttk.Label(op_frame, text="操作备注:").grid(row=0, column=0, sticky="w")
self.note_var = tk.StringVar()
self.note_entry = ttk.Entry(op_frame, textvariable=self.note_var, width=40)
self.note_entry.grid(row=0, column=1, padx=5)
# 级别选择
ttk.Label(op_frame, text="日志级别:").grid(row=0, column=2, padx=(10, 0))
self.level_var = tk.StringVar(value="INFO")
level_combo = ttk.Combobox(
op_frame, textvariable=self.level_var,
values=["DEBUG", "INFO", "WARNING", "ERROR"],
width=10, state="readonly"
)
level_combo.grid(row=0, column=3, padx=5)
# 操作按钮行
btn_frame = ttk.Frame(op_frame)
btn_frame.grid(row=1, column=0, columnspan=4, pady=(8, 0), sticky="w")
buttons = [
("📝 记录操作", self._log_custom),
("📂 打开文件", self._simulate_open_file),
("💾 保存数据", self._simulate_save),
("🔄 批量处理", self._simulate_batch),
("📤 导出 CSV", self._export_csv),
]
for text, cmd in buttons:
ttk.Button(btn_frame, text=text, command=cmd, width=14).pack(
side="left", padx=3
)
# ── 中部:日志查询区 ──
query_frame = ttk.LabelFrame(self, text="日志查询", padding=8)
query_frame.pack(fill="x", padx=10, pady=5)
ttk.Label(query_frame, text="关键词:").pack(side="left")
self.kw_var = tk.StringVar()
ttk.Entry(query_frame, textvariable=self.kw_var, width=20).pack(
side="left", padx=5
)
ttk.Label(query_frame, text="级别:").pack(side="left", padx=(10, 0))
self.filter_level = tk.StringVar(value="ALL")
ttk.Combobox(
query_frame, textvariable=self.filter_level,
values=["ALL", "DEBUG", "INFO", "WARNING", "ERROR"],
width=10, state="readonly"
).pack(side="left", padx=5)
ttk.Button(query_frame, text="🔍 查询", command=self._query_logs).pack(
side="left", padx=8
)
ttk.Button(query_frame, text="🗑️ 清空显示", command=self._clear_table).pack(
side="left"
)
# ── 下部:Treeview 日志表格 ──
table_frame = ttk.Frame(self)
table_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
columns = ("id", "timestamp", "level", "module", "action", "detail")
self.tree = ttk.Treeview(
table_frame, columns=columns, show="headings", height=18
)
col_config = {
"id": ("ID", 50),
"timestamp": ("时间", 160),
"level": ("级别", 70),
"module": ("模块", 80),
"action": ("操作", 150),
"detail": ("详情", 300),
}
for col, (heading, width) in col_config.items():
self.tree.heading(col, text=heading)
self.tree.column(col, width=width, anchor="w")
# 滚动条
vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
self.tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
# 行颜色区分级别
self.tree.tag_configure("ERROR", background="#ffe0e0")
self.tree.tag_configure("WARNING", background="#fff3cd")
self.tree.tag_configure("DEBUG", background="#f0f0f0")
# 初始加载最近记录
self._query_logs()
python def _log_custom(self):
note = self.note_var.get().strip()
if not note:
messagebox.showwarning("提示", "请先填写操作备注")
return
level = self.level_var.get()
getattr(self.log, level.lower())("自定义操作", note)
self.note_var.set("")
self._query_logs() # 刷新表格
def _simulate_open_file(self):
path = filedialog.askopenfilename(title="选择文件")
if path:
filename = os.path.basename(path)
self.log.info("打开文件", f"文件路径: {path}")
messagebox.showinfo("成功", f"已打开:{filename}")
self._query_logs()
def _simulate_save(self):
import time
start = time.time()
# 模拟耗时操作
self.after(100, lambda: None)
elapsed = int((time.time() - start) * 1000)
self.log.info("保存数据", "数据已写入本地缓存", duration_ms=elapsed)
self._query_logs()
def _simulate_batch(self):
"""在后台线程模拟批量处理,演示多线程安全写入"""
def worker():
import time
self.log.info("批量处理", "任务开始,共 50 条记录")
for i in range(1, 6):
time.sleep(0.2)
self.log.debug("批量处理", f"处理进度 {i*10}%")
self.log.info("批量处理", "任务完成,耗时约 1s", duration_ms=1000)
# 注意:不能在子线程直接操作 Tkinter 控件!
# 用 after() 把 UI 刷新调度回主线程
self.after(0, self._query_logs)
threading.Thread(target=worker, daemon=True).start()
def _query_logs(self):
rows = self.log.query(
level=self.filter_level.get(),
keyword=self.kw_var.get().strip() or None
)
self._clear_table()
for row in rows:
tag = row["level"] if row["level"] in ("ERROR", "WARNING", "DEBUG") else ""
self.tree.insert("", "end", values=(
row["id"], row["timestamp"], row["level"],
row["module"], row["action"], row["detail"]
), tags=(tag,))
def _clear_table(self):
for item in self.tree.get_children():
self.tree.delete(item)
def _export_csv(self):
path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV 文件", "*.csv")],
title="导出日志"
)
if not path:
return
rows = self.log.query(
level=self.filter_level.get(),
keyword=self.kw_var.get().strip() or None,
limit=10000
)
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["ID", "时间", "级别", "模块", "操作", "详情", "耗时(ms)"])
for row in rows:
writer.writerow([
row["id"], row["timestamp"], row["level"],
row["module"], row["action"], row["detail"],
row["duration_ms"]
])
self.log.info("导出日志", f"已导出至: {path}")
messagebox.showinfo("完成", f"日志已导出\n{path}")
self._query_logs()
if __name__ == "__main__":
app = MainApp()
app.mainloop()

坑一:子线程操作 Tkinter 控件会崩。 Tkinter 不是线程安全的,所有 UI 更新必须在主线程执行。_simulate_batch 里用了 self.after(0, self._query_logs),这是把回调"投递"到主线程事件循环,是标准做法,别图省事直接在子线程调 tree.insert()。
坑二:日志器重复初始化。 setup_file_logger 里加了 if logger.handlers: return logger 这个检查。Tkinter 应用如果有"重启"或"重置"逻辑,会再次初始化日志器,不加这个检查的话,每条日志会被写两遍、四遍……越来越多。
坑三:SQLite 并发写入。 默认的 SQLite 连接不支持多线程共享,DBLogger 里每次写入都新建连接(with sqlite3.connect(...) as conn),配合 _lock 保证串行,这是最稳的方案。如果追求性能,可以用 check_same_thread=False + WAL 模式,但那是另一个话题了。
坑四:中文路径问题。 TimedRotatingFileHandler 在 Windows 下用中文路径偶尔会有编码问题。建议日志目录统一用英文,encoding="utf-8" 加上,基本没问题。
启动后你会看到:
logs/ 目录下生成 app.log(文件日志)和 app_logs.db(SQLite 数据库)文件日志是给运维看的,数据库日志是给产品看的。 两者服务的受众不同,设计时就该分开对待。
after(0, callback)是 Tkinter 多线程的救命稻草。 记住这个模式,80% 的线程安全问题迎刃而解。
日志系统的价值,在出问题的那一刻才真正体现。 平时多花一小时搭好,事后省掉三天排查。
Python Tkinter SQLite logging 桌面开发 Windows开发
完整项目结构建议按 ui/、core/、logs/ 三个目录组织,LogManager 单独放 core/log_manager.py,复用起来更方便。源码已整理为单文件版本,直接运行即可验证效果。欢迎在评论区聊聊你在项目里用过的日志方案,或者踩过哪些坑。
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!