将 Photoprism 的 SQLite 放置于 tmpfs 上
警告:把数据库放在临时存储上是发疯行为。你可能会被 DBA 暴打,并且遭遇因 断电/程序崩溃/容器崩溃/系统崩溃/宇宙射线 导致的灾难性数据丢失。
阅读时间 2 分钟
最重要的警告写在最开头。磁盘文件系统通常是可靠的,SQLite 在这一前提下设计,通常不会导致灾难性数据丢失。但当你把数据放在不可靠的存储上,并且使用复制数据库这种荒诞的方式持久化存储,那么即使一个 kill -9
信号中断持久化进程都有可能导致数据损坏。此外,我没有验证过复制一个正在写入的数据库文件,会得到什么状态的结果,或许会得到 commit 前的状态,或许会得到损坏的数据库。
把所有照片导入自建的 Photoprism 进行管理,至今已经过去 3 年。我不是特别热衷于拍照的人,即便如此,照片总数也超过了 1 万张。这个数量级的照片足以击垮我的 Photoprism 实例,我在正常浏览照片的时候会频繁出现 Database is locked
错误。很明显,这是单线程的 SQLite 无法在段时间内处理浏览器发过来的多个请求。
我需要提前声明,我没有说 Photoprism 性能很差。出现这种错误的原因主要是我“错误的”选择,责任基本在我。Photoprism 官方建议使用的数据库是 MySQL(或者说 MariaDB),但我不喜欢 MySQL。加上出于成本和可靠性考虑,我把所有照片和 SQLite 数据库都放在 RAID1 的机械硬盘上。这导致一个非常普通的、使用索引的查询都能消耗数秒。
某天晚上睡觉前,我将最近手机上拍摄的照片导入 Photoprism。等待了半个小时还没导入完,我受不了了,睡了。然后我做了一个梦,梦到我的 SQLite 数据库运行在 tmpfs 上,速度快到起飞,又能定时将数据刷写到可靠的机械硬盘中。没错,就像是 redis 版 SQLite。
我醒了,感觉这个方案非常可行。只要在 Photoprism 启动前,把数据库文件复制到 tmpfs 中,然后根据文件的修改时间,定期复制文件回磁盘。再处理好一些同时读写的边界问题,就大功告成了。
于是立即召唤 GPT,下达指令:
写一个python脚本,要求:
- 替换 print 函数,将输出到 stderr
- 复制 /photoprism/storage/index.db 文件到 /tmpfs/index.db, 然后启动photoprism 程序,启动命令是 /opt/photoprism/bin/photoprism –database-dsn /tmpfs/index.db start
- 启动 photoprism 之后启动一个复制线程,定时检查 /tmpfs/index.db 的修改时间,如果发生了修改,则复制 /tmpfs/index.db 到 /photoprism/storage/index.db
- 捕获退出信号,优雅结束 photoprism 并 复制 /tmpfs/index.db 到 /photoprism/storage/index.db。如果复制线程正在复制,等待复制完成
一分钟,花费 3 RMB,经过一些细节的修改,我得到了最终的脚本:
import sys
import builtins
import shutil
import threading
import time
import subprocess
import os
import signal
# 替换 print 函数,将输出到 stderr
def print(*args, **kwargs):
kwargs['file'] = sys.stderr
builtins.print(*args, **kwargs)
class PhotoprismController:
def __init__(self):
self.storage_index_db = '/photoprism/storage/index.db'
self.tmp_index_db = '/tmpfs/index.db'
self.copy_lock = threading.Lock()
self.terminate_event = threading.Event()
self.photoprism_process = None
self.copy_thread = None
def copy_index_db(self, src, dest):
with self.copy_lock:
shutil.copy2(src, dest)
def copy_back_thread_func(self):
print("复制线程已启动。")
last_mtime = os.path.getmtime(self.tmp_index_db)
while not self.terminate_event.is_set():
try:
if os.path.exists(self.tmp_index_db):
mtime = os.path.getmtime(self.tmp_index_db)
if last_mtime is None:
last_mtime = mtime
elif mtime != last_mtime:
print("检测到 index.db 修改,正在复制回存储目录...")
self.copy_index_db(self.tmp_index_db, self.storage_index_db)
print("检测到 index.db 修改,正在复制回存储目录...(完成)")
last_mtime = mtime
except Exception as e:
print(f"复制线程错误: {e}")
time.sleep(60*60)
print("复制线程已退出。")
def signal_handler(self, signum, frame):
print(f"接收到信号 {signum},正在关闭...")
self.terminate_event.set()
if self.photoprism_process and self.photoprism_process.poll() is None:
print("正在终止 photoprism 进程...")
self.photoprism_process.terminate()
try:
self.photoprism_process.wait(timeout=3)
except subprocess.TimeoutExpired:
print("photoprism 未能及时终止,强制结束。")
self.photoprism_process.kill()
def run(self):
# 复制 index.db 到 /tmpfs
print("正在复制 index.db 到 /tmpfs...")
try:
shutil.copy2(self.storage_index_db, self.tmp_index_db)
print("正在复制 index.db 到 /tmpfs...(完成)")
except Exception as e:
print(f"复制到 /tmpfs 时出错: {e}")
sys.exit(1)
# 启动 photoprism 程序
photoprism_cmd = ['/opt/photoprism/bin/photoprism', '--database-dsn', self.tmp_index_db, 'start']
print("正在启动 photoprism...")
self.photoprism_process = subprocess.Popen(photoprism_cmd)
# 注册信号处理器
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
# 启动复制线程
self.copy_thread = threading.Thread(target=self.copy_back_thread_func, daemon=True)
self.copy_thread.start()
try:
while True:
# 等待 photoprism 进程退出或接收到终止事件
retcode = self.photoprism_process.poll()
if retcode is not None:
print(f"photoprism 进程已退出,返回码 {retcode}")
break
if self.terminate_event.is_set():
# signal_handler 已处理终止
break
time.sleep(1)
except Exception as e:
print(f"主循环中出现错误: {e}")
finally:
self.terminate_event.set()
# 在退出前复制 index.db 回存储目录
print("正在将 index.db 复制回存储目录...")
with self.copy_lock:
try:
shutil.copy2(self.tmp_index_db, self.storage_index_db)
print("正在将 index.db 复制回存储目录...(完成)")
except Exception as e:
print(f"复制回存储目录时出错: {e}")
# 等待复制线程结束
#print("等待复制线程结束...")
#self.copy_thread.join()
print("获取复制锁")
self.copy_lock.acquire()
print("脚本已退出。")
def main():
controller = PhotoprismController()
controller.run()
if __name__ == '__main__':
main()
它工作非常好,但我唯一不太满意的地方,就是 try catch 导致的缩进有点多,让整个程序看起来不是很精炼整洁。
脚本保存到 ./scripts/start.py
位置,下一步就是修改 docker-compose.yaml
,挂载对应的 tmpfs 和脚本,然后修改 photoprism 的启动入口。修改后的 yaml 文件如下(已经省去不必要的部分)
services:
photoprism:
# (其他配置...)
tmpfs:
- /tmpfs:rw,size=1g
volumes:
# (其他挂载点...)
- "./scripts:/scripts"
entrypoint: python3 /scripts/start.py
最后启动容器,并按下 Ctrl + C
发送终止信号测试执行效果。
如果你真像我这么干了,朋友,记住你在玩火!定期备份数据!