水族馆

将 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脚本,要求:

一分钟,花费 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 发送终止信号测试执行效果。

如果你真像我这么干了,朋友,记住你在玩火!定期备份数据!