<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Migrate on Wilson Wu</title><link>https://wilsonwu.me/zh/tags/migrate/</link><description>Recent content in Migrate on Wilson Wu</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Thu, 13 Aug 2026 00:00:00 -0400</lastBuildDate><atom:link href="https://wilsonwu.me/zh/tags/migrate/index.xml" rel="self" type="application/rss+xml"/><item><title>OpenWebUI 数据库迁移实践：从 VM 自建 PostgreSQL 平滑迁移到 Azure 托管 PostgreSQL</title><link>https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/</link><pubDate>Thu, 13 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/</guid><description>&lt;p&gt;&lt;img alt="迁移" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/1-overview.png"&gt;&lt;/p&gt;
&lt;p&gt;随着 OpenWebUI 中的用户、对话、文件和知识库数据不断增长，原先部署在 Azure VM 上的自建 PostgreSQL 带来了越来越多的运维工作，包括数据库补丁、备份、故障恢复、存储监控和安全维护。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="迁移" loading="lazy" src="/zh/blog/2026/migrate-openwebui-to-azure-pg/1-overview.png"></p>
<p>随着 OpenWebUI 中的用户、对话、文件和知识库数据不断增长，原先部署在 Azure VM 上的自建 PostgreSQL 带来了越来越多的运维工作，包括数据库补丁、备份、故障恢复、存储监控和安全维护。</p>
<p>为了降低维护成本并提高数据库可靠性，我将 <strong>OpenWebUI 的业务数据库</strong>从 VM 本地 PostgreSQL 迁移到了 <strong>Azure Database for PostgreSQL Flexible Server</strong>。</p>
<p>这次迁移采用了：</p>

<blockquote>
  <p>在线全量迁移 + 数据一致性验证 + 活跃对话增量同步 + 快速切换</p>

</blockquote>
<p>全量数据库导出和恢复期间，OpenWebUI 始终保持在线。最终切换仅涉及一次很短的 Docker 容器重建，用户侧基本无感。</p>

<blockquote>
  <p>本文中的服务器地址、数据库名称、用户名、密码和资源标识均已隐藏或替换为占位符。</p>

</blockquote>
<h2 id="迁移背景">迁移背景</h2>
<p>OpenWebUI 以 Docker 容器形式运行在 Azure VM 中，最初使用安装在同一台 VM 上的 PostgreSQL。</p>
<p>迁移前的架构如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>Azure VM
</span></span><span style="display:flex;"><span>  ├── OpenWebUI Docker 容器
</span></span><span style="display:flex;"><span>  │      └── 连接 VM 宿主机 PostgreSQL
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  └── PostgreSQL
</span></span><span style="display:flex;"><span>         └── 数据保存在 VM 本地磁盘
</span></span></code></pre></div><p>这种部署方式比较简单，但随着应用逐渐稳定运行，数据库维护也成为了新的负担。</p>
<p>需要自行处理的工作包括：</p>
<ul>
<li>PostgreSQL 安全更新和补丁</li>
<li>数据库备份及备份轮换</li>
<li>数据恢复和恢复演练</li>
<li>磁盘容量监控</li>
<li>数据库服务监控</li>
<li>PostgreSQL 版本升级</li>
<li>故障处理</li>
<li>网络和访问权限管理</li>
</ul>
<p>对于 OpenWebUI 这样的应用来说，数据库是重要的状态组件，但并不是应用的核心运维目标。因此，将 PostgreSQL 迁移到 Azure 托管服务是一个更合理的选择。</p>
<h2 id="迁移后的架构">迁移后的架构</h2>
<p>迁移后的架构如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>Azure VM
</span></span><span style="display:flex;"><span>  └── OpenWebUI Docker 容器
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          ├── Private Endpoint
</span></span><span style="display:flex;"><span>          │       └── Azure Database for PostgreSQL
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          └── 宿主机持久化目录
</span></span><span style="display:flex;"><span>                  ├── uploads
</span></span><span style="display:flex;"><span>                  ├── vector_db
</span></span><span style="display:flex;"><span>                  └── cache
</span></span></code></pre></div><p>最终实现了：</p>
<ul>
<li>OpenWebUI 使用 Azure 托管 PostgreSQL</li>
<li>数据库通过 Private Endpoint 私网访问</li>
<li>数据库连接启用 TLS</li>
<li>PostgreSQL 不再占用 VM 本地端口和磁盘</li>
<li>OpenWebUI 上传文件和向量数据实现持久化挂载</li>
<li>Azure 自动执行数据库备份</li>
<li>VM 上只保留 PostgreSQL 客户端，用于日常管理 Azure 数据库</li>
</ul>
<p><img alt="架构" loading="lazy" src="/zh/blog/2026/migrate-openwebui-to-azure-pg/2-architecture.png"></p>
<h2 id="为什么选择-azure-database-for-postgresql">为什么选择 Azure Database for PostgreSQL</h2>
<h3 id="1-降低数据库运维成本">1. 降低数据库运维成本</h3>
<p>Azure Database for PostgreSQL Flexible Server 是一项托管数据库服务。</p>
<p>与 VM 自建 PostgreSQL 相比，Azure 可以帮助处理大量底层工作，例如：</p>
<ul>
<li>数据库基础设施维护</li>
<li>自动备份</li>
<li>事务日志备份</li>
<li>时间点恢复</li>
<li>监控指标采集</li>
<li>存储管理</li>
<li>计划性维护</li>
<li>可选高可用</li>
<li>可选只读副本</li>
</ul>
<p>迁移完成后，VM 只需要负责运行 OpenWebUI，数据库生命周期则交给 Azure 管理。</p>
<h3 id="2-自动备份和时间点恢复">2. 自动备份和时间点恢复</h3>
<p>Azure PostgreSQL 提供内置自动备份，并持续备份事务日志，支持时间点恢复（Point-in-Time Restore，PITR）。</p>
<p>当前环境配置了自动备份保留策略。发生误操作时，可以在保留期内创建一台恢复到指定时间点的新服务器。</p>
<p>与自行编写 <code>pg_dump</code> 和 Cron 脚本相比，Azure 托管备份具有以下优势：</p>
<ul>
<li>不需要维护备份脚本</li>
<li>不需要手动轮换备份文件</li>
<li>不会占用 VM 本地磁盘</li>
<li>可以恢复到保留期内的特定时间</li>
<li>备份操作由平台统一管理</li>
</ul>
<p>需要注意的是，Azure 内置备份主要用于平台恢复，不等同于可随意下载的逻辑备份文件。</p>
<p>如果有以下需求，仍建议额外定期执行 <code>pg_dump</code>：</p>
<ul>
<li>长期离线归档</li>
<li>跨平台恢复</li>
<li>数据审计</li>
<li>跨订阅或跨云迁移</li>
<li>保留期超过平台备份策略</li>
</ul>
<h3 id="3-使用-private-endpoint-私网访问">3. 使用 Private Endpoint 私网访问</h3>
<p>本次迁移没有将 PostgreSQL 直接暴露到公网，而是为 Azure PostgreSQL 配置了 Private Endpoint。</p>
<p>OpenWebUI 访问数据库时，数据库域名通过 Private DNS 解析到 VNet 中的私有 IP：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Azure PostgreSQL FQDN
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>Private DNS
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>Private Endpoint 私有 IP
</span></span></code></pre></div><p>从 OpenWebUI 容器内部进行了两层验证：</p>
<ol>
<li>DNS 是否解析到私有地址</li>
<li>TCP 5432 是否可以通过私网访问</li>
</ol>
<p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec -i open-webui python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import socket
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">host = &#34;&lt;AZURE_POSTGRESQL_PRIVATE_FQDN&gt;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">port = 5432
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">addresses = sorted({
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    item[4][0]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    for item in socket.getaddrinfo(
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        host,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        port,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        type=socket.SOCK_STREAM
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    )
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">})
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Resolved addresses:&#34;, addresses)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">with socket.create_connection((host, port), timeout=5) as sock:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    print(&#34;TCP connection: OK&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    print(&#34;Connected peer:&#34;, sock.getpeername())
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>数据库安全性并不只是来自 Private Endpoint，而是来自以下措施的组合：</p>
<ul>
<li>Private Endpoint</li>
<li>Private DNS</li>
<li>VNet 隔离</li>
<li>NSG 和路由控制</li>
<li>禁用或限制公网访问</li>
<li>PostgreSQL 身份认证</li>
<li>TLS 链路加密</li>
<li>最小权限数据库账号</li>
</ul>
<h3 id="4-数据库连接启用-tls">4. 数据库连接启用 TLS</h3>
<p>OpenWebUI 的数据库连接串启用了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sslmode=require
</span></span></code></pre></div><p>脱敏后的连接串格式如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgresql://&lt;APP_USER&gt;:&lt;PASSWORD&gt;@&lt;AZURE_POSTGRESQL_FQDN&gt;:5432/&lt;APP_DATABASE&gt;?sslmode=require
</span></span></code></pre></div><p>迁移前还通过 OpenWebUI 使用的 Python 和 SQLAlchemy 环境实际验证了 TLS：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> create_engine, text
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>engine <span style="color:#f92672">=</span> create_engine(os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#34;DATABASE_URL&#34;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">with</span> engine<span style="color:#f92672">.</span>connect() <span style="color:#66d9ef">as</span> conn:
</span></span><span style="display:flex;"><span>    ssl_enabled <span style="color:#f92672">=</span> conn<span style="color:#f92672">.</span>execute(text(<span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SELECT ssl
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        FROM pg_stat_ssl
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        WHERE pid = pg_backend_pid()
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>))<span style="color:#f92672">.</span>scalar_one()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;TLS enabled:&#34;</span>, ssl_enabled)
</span></span></code></pre></div><p>如果安全要求更高，可以进一步使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sslmode=verify-full
</span></span></code></pre></div><p>这种模式不仅加密连接，还会验证证书和服务器主机名，但需要正确配置 CA 证书，并使用与证书匹配的服务器 FQDN。</p>
<h3 id="5-更容易扩展可靠性能力">5. 更容易扩展可靠性能力</h3>
<p>迁移到 Azure PostgreSQL 后，可以根据 OpenWebUI 的使用规模继续配置：</p>
<ul>
<li>计算规格扩缩容</li>
<li>存储扩容</li>
<li>可用区高可用</li>
<li>只读副本</li>
<li>慢查询分析</li>
<li>数据库连接监控</li>
<li>CPU、内存和存储告警</li>
<li>更长的备份保留期</li>
<li>异地恢复策略</li>
</ul>
<p>这使 OpenWebUI 的数据库层从“单 VM 上的本地服务”升级为独立、可管理的托管数据服务。</p>
<h2 id="迁移目标让-openwebui-尽量保持在线">迁移目标：让 OpenWebUI 尽量保持在线</h2>
<p>传统迁移通常采用以下流程：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>停止应用
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>导出数据库
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>恢复目标数据库
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>修改连接配置
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>重新启动应用
</span></span></code></pre></div><p>这种方式虽然简单，但数据库越大，停机时间越长。</p>
<p>本次迁移采用了不同策略：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>在线全量备份
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>在线恢复到 Azure PostgreSQL
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>验证结构、权限和数据
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>同步迁移期间新增的活跃对话
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>快速重建 OpenWebUI 容器
</span></span></code></pre></div><p>在以下阶段，OpenWebUI 都保持在线：</p>
<ul>
<li>全量 <code>pg_dump</code></li>
<li>Azure PostgreSQL 数据恢复</li>
<li>表结构验证</li>
<li>数据量对比</li>
<li>Private Endpoint 验证</li>
<li>TLS 验证</li>
<li>增量数据同步</li>
</ul>
<p>最终只有更换 <code>DATABASE_URL</code> 并重建容器时发生了短暂连接中断。</p>
<p>因此，更准确地说，这是一种：</p>

<blockquote>
  <p><strong>近零停机、用户侧近乎无感知的 OpenWebUI 数据库迁移方案。</strong></p>

</blockquote>
<h2 id="迁移前检查-openwebui">迁移前检查 OpenWebUI</h2>
<h3 id="1-确认-openwebui-使用的数据库类型">1. 确认 OpenWebUI 使用的数据库类型</h3>
<p>首先检查 OpenWebUI 容器环境变量：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect open-webui <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format <span style="color:#e6db74">&#39;{{range .Config.Env}}{{println .}}{{end}}&#39;</span> |
</span></span><span style="display:flex;"><span>grep -E <span style="color:#e6db74">&#39;DATABASE_URL|DATA_DIR|WEBUI_SECRET_KEY&#39;</span>
</span></span></code></pre></div><p>确认 OpenWebUI 已经使用 PostgreSQL，而不是默认 SQLite：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DATABASE_URL=postgresql://&lt;LOCAL_USER&gt;:***@&lt;LOCAL_HOST&gt;:5432/&lt;LOCAL_DATABASE&gt;
</span></span></code></pre></div><p>由于源端和目标端都是 PostgreSQL，因此可以直接使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>pg_dump → pg_restore
</span></span></code></pre></div><p>不需要使用 SQLite 到 PostgreSQL 的转换工具。</p>
<h3 id="2-检查源数据库">2. 检查源数据库</h3>
<p>检查 PostgreSQL 版本和数据库大小：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d &lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">SELECT
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  current_database(),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  current_setting(&#39;server_version&#39;),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  pg_size_pretty(
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    pg_database_size(current_database())
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  );
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>同时检查源数据库使用了哪些扩展：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d &lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">SELECT extname, extversion
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">FROM pg_extension
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">ORDER BY extname;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>本次源数据库没有使用额外的第三方扩展，因此降低了跨环境迁移的兼容性风险。</p>
<h3 id="3-检查-openwebui-的本地文件">3. 检查 OpenWebUI 的本地文件</h3>
<p>数据库并不是 OpenWebUI 的全部数据。</p>
<p>检查容器挂载后发现，原容器没有配置持久化 volume，而 <code>/app/backend/data</code> 中已经存在大量数据：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/app/backend/data
</span></span><span style="display:flex;"><span>├── cache
</span></span><span style="display:flex;"><span>├── uploads
</span></span><span style="display:flex;"><span>└── vector_db
</span></span></code></pre></div><p>这些内容包括：</p>
<ul>
<li>用户上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>其他应用本地状态</li>
</ul>
<p>它们不会进入 PostgreSQL 的 <code>pg_dump</code>，因此必须单独处理。</p>
<p>这也是本次迁移中一个非常重要的发现：</p>

<blockquote>
  <p>迁移 OpenWebUI 的 PostgreSQL，并不等于迁移了 OpenWebUI 的全部数据。</p>

</blockquote>
<h2 id="准备-azure-postgresql">准备 Azure PostgreSQL</h2>
<h3 id="1-创建独立应用账号">1. 创建独立应用账号</h3>
<p>不建议让 OpenWebUI 长期使用 Azure PostgreSQL 管理员账号。</p>
<p>在目标服务器中创建专用角色：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">ROLE</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span> LOGIN;
</span></span></code></pre></div><p>通过 <code>psql</code> 交互方式设置密码，可以避免密码出现在 Shell 历史记录中：</p>
<pre tabindex="0"><code class="language-psql" data-lang="psql">\password &lt;APP_USER&gt;
</code></pre><p>然后创建目标数据库：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">DATABASE</span> <span style="color:#f92672">&lt;</span>APP_DATABASE<span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">OWNER</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span></code></pre></div><h3 id="2-验证应用账号">2. 验证应用账号</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;host=&lt;AZURE_POSTGRESQL_FQDN&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   port=5432 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   dbname=&lt;APP_DATABASE&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   user=&lt;APP_USER&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   sslmode=require&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -W <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;SELECT current_user, current_database();&#34;</span>
</span></span></code></pre></div><p>同时确认目标数据库为空：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> information_schema.tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> table_schema <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> table_type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;BASE TABLE&#39;</span>;
</span></span></code></pre></div><h3 id="3-处理-public-schema-权限">3. 处理 <code>public</code> schema 权限</h3>
<p>第一次测试恢复时遇到了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ERROR: permission denied for schema public
</span></span></code></pre></div><p>原因是应用账号虽然可以连接数据库，但没有在 <code>public</code> schema 中创建对象的权限。</p>
<p>使用管理员账号连接目标数据库后执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">SCHEMA</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">OWNER</span> <span style="color:#66d9ef">TO</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GRANT</span> <span style="color:#66d9ef">USAGE</span>, <span style="color:#66d9ef">CREATE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">SCHEMA</span> <span style="color:#66d9ef">public</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">TO</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span></code></pre></div><p>这个问题说明，仅验证账号“能否登录”还不够，还需要确认该账号能否：</p>
<ul>
<li>创建表</li>
<li>创建索引</li>
<li>创建序列</li>
<li>创建约束</li>
<li>执行 OpenWebUI 的 Alembic 数据库迁移</li>
</ul>
<h2 id="在线执行全量数据库迁移">在线执行全量数据库迁移</h2>
<h3 id="1-在线导出-openwebui-数据库">1. 在线导出 OpenWebUI 数据库</h3>
<p>PostgreSQL 的 <code>pg_dump</code> 会基于一致性事务快照导出数据，因此可以在 OpenWebUI 继续运行时执行。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres pg_dump <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --dbname<span style="color:#f92672">=</span>&lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format<span style="color:#f92672">=</span>custom <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-owner <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-acl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --file<span style="color:#f92672">=</span>/tmp/openwebui-pre-migration.dump
</span></span></code></pre></div><p>参数含义：</p>
<ul>
<li><code>--format=custom</code>：使用 PostgreSQL 自定义归档格式</li>
<li><code>--no-owner</code>：不恢复源端对象 owner</li>
<li><code>--no-acl</code>：不恢复源端权限</li>
<li><code>--file</code>：指定备份文件</li>
</ul>
<p>检查归档是否有效：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pg_restore <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --list <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp/openwebui-pre-migration.dump |
</span></span><span style="display:flex;"><span>head -30
</span></span></code></pre></div><p>自定义格式会保留：</p>
<ul>
<li>表结构</li>
<li>表数据</li>
<li>主键</li>
<li>唯一约束</li>
<li>索引</li>
<li>外键</li>
<li>序列</li>
<li>Alembic 迁移版本</li>
</ul>
<h3 id="2-恢复到-azure-postgresql">2. 恢复到 Azure PostgreSQL</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pg_restore <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --host<span style="color:#f92672">=</span>&lt;AZURE_POSTGRESQL_FQDN&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --port<span style="color:#f92672">=</span><span style="color:#ae81ff">5432</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --username<span style="color:#f92672">=</span>&lt;APP_USER&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --dbname<span style="color:#f92672">=</span>&lt;APP_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-owner <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-acl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --exit-on-error <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --verbose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp/openwebui-pre-migration.dump
</span></span></code></pre></div><p>使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>--exit-on-error
</span></span></code></pre></div><p>可以确保遇到恢复错误时立即停止，避免忽略关键问题。</p>
<p>恢复成功后更新统计信息：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;host=&lt;AZURE_POSTGRESQL_FQDN&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   port=5432 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   dbname=&lt;APP_DATABASE&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   user=&lt;APP_USER&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   sslmode=require&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -W <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;ANALYZE;&#34;</span>
</span></span></code></pre></div><h2 id="对比源端与目标端数据">对比源端与目标端数据</h2>
<p>恢复完成后，需要对 OpenWebUI 的核心表进行对比。</p>
<p>示例查询：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">&#34;user&#34;</span>) <span style="color:#66d9ef">AS</span> users,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> chat) <span style="color:#66d9ef">AS</span> chats,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> chat_message) <span style="color:#66d9ef">AS</span> chat_messages,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> file) <span style="color:#66d9ef">AS</span> files,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> knowledge) <span style="color:#66d9ef">AS</span> knowledge,
</span></span><span style="display:flex;"><span>  (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> information_schema.tables
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> table_schema <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">AND</span> table_type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;BASE TABLE&#39;</span>
</span></span><span style="display:flex;"><span>  ) <span style="color:#66d9ef">AS</span> tables;
</span></span></code></pre></div><p>重点检查：</p>
<ul>
<li>用户数量</li>
<li>对话数量</li>
<li>消息数量</li>
<li>文件记录数量</li>
<li>知识库数量</li>
<li>业务表数量</li>
<li>Alembic 版本</li>
<li>表 owner</li>
<li>序列状态</li>
</ul>
<p>对比时发现，目标端的对话消息比源端少了少量记录。</p>
<p>这并不是恢复失败，而是因为：</p>

<blockquote>
  <p>在全量备份和恢复过程中，OpenWebUI 仍然在线，当前迁移操作的对话还在不断产生新消息。</p>

</blockquote>
<p>因此，下一步需要同步全量快照之后新增的数据。</p>
<h2 id="同步迁移期间新增的活跃对话">同步迁移期间新增的活跃对话</h2>
<p>本次迁移期间，主要只有当前操作对话仍在更新，因此没有再次恢复整个数据库，而是只同步当前活跃对话。</p>
<p>涉及两张表：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>chat
</span></span><span style="display:flex;"><span>chat_message
</span></span></code></pre></div><h3 id="1-找到最近更新的对话">1. 找到最近更新的对话</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.id,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.title,
</span></span><span style="display:flex;"><span>  to_timestamp(<span style="color:#66d9ef">c</span>.updated_at) <span style="color:#66d9ef">AS</span> updated_at,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">count</span>(cm.id) <span style="color:#66d9ef">AS</span> message_count
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> chat <span style="color:#66d9ef">c</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LEFT</span> <span style="color:#66d9ef">JOIN</span> chat_message cm
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ON</span> cm.chat_id <span style="color:#f92672">=</span> <span style="color:#66d9ef">c</span>.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GROUP</span> <span style="color:#66d9ef">BY</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.id,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.title,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.updated_at
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> <span style="color:#66d9ef">c</span>.updated_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">5</span>;
</span></span></code></pre></div><p>确定活跃对话后，后续脚本通过环境变量或参数接收对话 ID，而不是将其直接写入公开文档：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>CHAT_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;&lt;ACTIVE_CHAT_ID&gt;&#34;</span>
</span></span></code></pre></div><h3 id="2-使用一致性快照导出对话数据">2. 使用一致性快照导出对话数据</h3>
<p>为了保证 <code>chat</code> 和 <code>chat_message</code> 来自同一个时间点，使用只读、可重复读事务：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span> <span style="color:#66d9ef">TRANSACTION</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ISOLATION</span> <span style="color:#66d9ef">LEVEL</span> <span style="color:#66d9ef">REPEATABLE</span> <span style="color:#66d9ef">READ</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">READ</span> <span style="color:#66d9ef">ONLY</span>;
</span></span></code></pre></div><p>然后在同一事务中导出：</p>
<pre tabindex="0"><code class="language-psql" data-lang="psql">\copy (
  SELECT *
  FROM chat
  WHERE id = &#39;&lt;ACTIVE_CHAT_ID&gt;&#39;
) TO &#39;/tmp/chat.csv&#39;
WITH (FORMAT CSV);
</code></pre><pre tabindex="0"><code class="language-psql" data-lang="psql">\copy (
  SELECT *
  FROM chat_message
  WHERE chat_id = &#39;&lt;ACTIVE_CHAT_ID&gt;&#39;
  ORDER BY created_at, id
) TO &#39;/tmp/chat-messages.csv&#39;
WITH (FORMAT CSV);
</code></pre><p>最后提交：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></div><p>需要注意：<code>psql</code> 的 <code>\copy</code> 是客户端元命令，复杂字段列表最好写在同一行，否则可能出现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>\copy: parse error at end of line
</span></span></code></pre></div><h3 id="3-在-azure-中使用临时表和-upsert">3. 在 Azure 中使用临时表和 UPSERT</h3>
<p>目标端创建临时 staging 表：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> TEMP <span style="color:#66d9ef">TABLE</span> stage_chat
</span></span><span style="display:flex;"><span>(<span style="color:#66d9ef">LIKE</span> <span style="color:#66d9ef">public</span>.chat <span style="color:#66d9ef">INCLUDING</span> <span style="color:#66d9ef">DEFAULTS</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> TEMP <span style="color:#66d9ef">TABLE</span> stage_chat_message
</span></span><span style="display:flex;"><span>(<span style="color:#66d9ef">LIKE</span> <span style="color:#66d9ef">public</span>.chat_message <span style="color:#66d9ef">INCLUDING</span> <span style="color:#66d9ef">DEFAULTS</span>);
</span></span></code></pre></div><p>将 CSV 导入临时表后，对 <code>chat</code> 主记录执行 UPSERT：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.chat (...)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stage_chat
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> CONFLICT (id) <span style="color:#66d9ef">DO</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">SET</span>
</span></span><span style="display:flex;"><span>  title              <span style="color:#f92672">=</span> EXCLUDED.title,
</span></span><span style="display:flex;"><span>  updated_at         <span style="color:#f92672">=</span> EXCLUDED.updated_at,
</span></span><span style="display:flex;"><span>  chat               <span style="color:#f92672">=</span> EXCLUDED.chat,
</span></span><span style="display:flex;"><span>  meta               <span style="color:#f92672">=</span> EXCLUDED.meta,
</span></span><span style="display:flex;"><span>  current_message_id <span style="color:#f92672">=</span> EXCLUDED.current_message_id;
</span></span></code></pre></div><p>对消息表执行类似操作：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.chat_message (...)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stage_chat_message
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> CONFLICT (id) <span style="color:#66d9ef">DO</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">SET</span>
</span></span><span style="display:flex;"><span>  content         <span style="color:#f92672">=</span> EXCLUDED.content,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">output</span>          <span style="color:#f92672">=</span> EXCLUDED.<span style="color:#66d9ef">output</span>,
</span></span><span style="display:flex;"><span>  status_history  <span style="color:#f92672">=</span> EXCLUDED.status_history,
</span></span><span style="display:flex;"><span>  error           <span style="color:#f92672">=</span> EXCLUDED.error,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">usage</span>           <span style="color:#f92672">=</span> EXCLUDED.<span style="color:#66d9ef">usage</span>,
</span></span><span style="display:flex;"><span>  done            <span style="color:#f92672">=</span> EXCLUDED.done,
</span></span><span style="display:flex;"><span>  updated_at      <span style="color:#f92672">=</span> EXCLUDED.updated_at,
</span></span><span style="display:flex;"><span>  context_summary <span style="color:#f92672">=</span> EXCLUDED.context_summary;
</span></span></code></pre></div><p>为了让 Azure 中该对话的消息集合与源快照完全一致，还会删除目标端多余记录：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">FROM</span> <span style="color:#66d9ef">public</span>.chat_message target
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> target.chat_id <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&lt;ACTIVE_CHAT_ID&gt;&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">EXISTS</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> stage_chat_message staged
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> staged.id <span style="color:#f92672">=</span> target.id
</span></span><span style="display:flex;"><span>  );
</span></span></code></pre></div><p>整个导入和 UPSERT 过程放在一个事务中执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- 创建临时表
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- 导入数据
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- UPSERT
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- 清理多余记录
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></div><p>如果任何步骤失败，事务会自动回滚，不会留下部分同步数据。</p>
<h2 id="最终快速切换-openwebui">最终快速切换 OpenWebUI</h2>
<p>完成在线全量迁移后，最终操作为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>最后同步一次当前活跃对话
</span></span><span style="display:flex;"><span>              ↓
</span></span><span style="display:flex;"><span>重建 OpenWebUI 容器
</span></span><span style="display:flex;"><span>              ↓
</span></span><span style="display:flex;"><span>新容器连接 Azure PostgreSQL
</span></span></code></pre></div><p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>./sync-active-chat-to-azure.sh <span style="color:#f92672">&amp;&amp;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  up -d --force-recreate
</span></span></code></pre></div><p>完整数据库已经提前迁移并验证，因此最终阶段不需要再次等待几百 MB 数据恢复，只需要同步少量增量记录并快速重建容器。</p>
<p>这将切换窗口缩短到了容器启动所需的时间。</p>
<h2 id="安全保存-openwebui-数据库连接串">安全保存 OpenWebUI 数据库连接串</h2>
<p>数据库连接串没有直接写入 Compose 文件，而是放在独立环境文件中：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>open-webui-azure-db.env
</span></span></code></pre></div><p>文件内容形式：</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">DATABASE_URL=postgresql://&lt;APP_USER&gt;:&lt;URL_ENCODED_PASSWORD&gt;@&lt;AZURE_POSTGRESQL_FQDN&gt;:5432/&lt;APP_DATABASE&gt;?sslmode=require
</code></pre><p>文件权限设置为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chmod <span style="color:#ae81ff">600</span> open-webui-azure-db.env
</span></span></code></pre></div><p>如果密码包含特殊字符，需要先进行 URL 编码：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import getpass
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import urllib.parse
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">password = getpass.getpass(&#34;Database password: &#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(urllib.parse.quote(password, safe=&#34;&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>新的 Compose 文件示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">open-webui</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/open-webui/open-webui:&lt;VERSION&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">open-webui</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">always</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8080:8080&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">env_file</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./open-webui.env</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./open-webui-azure-db.env</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/opt/open-webui/data:/app/backend/data</span>
</span></span></code></pre></div><h2 id="持久化-openwebui-本地文件">持久化 OpenWebUI 本地文件</h2>
<p>迁移前，OpenWebUI 的 <code>/app/backend/data</code> 没有挂载到宿主机。</p>
<p>先将数据复制出来：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo install <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -m <span style="color:#ae81ff">700</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -un<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -g <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -gn<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /opt/open-webui/data
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker cp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  open-webui:/app/backend/data/. <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /opt/open-webui/data/
</span></span></code></pre></div><p>在 Compose 中加入：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">/opt/open-webui/data:/app/backend/data</span>
</span></span></code></pre></div><p>这样，即使未来重建或升级 OpenWebUI 容器，以下内容仍会保留：</p>
<ul>
<li>上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>其他本地应用数据</li>
</ul>
<p>迁移后验证：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect open-webui <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format <span style="color:#e6db74">&#39;{{range .Mounts}}{{println .Source &#34;-&gt;&#34; .Destination &#34;rw=&#34; .RW}}{{end}}&#39;</span>
</span></span></code></pre></div><p>预期：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/opt/open-webui/data -&gt; /app/backend/data rw=true
</span></span></code></pre></div><h2 id="迁移后的验证">迁移后的验证</h2>
<h3 id="1-openwebui-健康检查">1. OpenWebUI 健康检查</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -sS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o /dev/null <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -w <span style="color:#e6db74">&#39;HTTP %{http_code}\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --max-time <span style="color:#ae81ff">10</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  http://127.0.0.1:8080/health
</span></span></code></pre></div><p>预期：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>HTTP 200
</span></span></code></pre></div><h3 id="2-验证-openwebui-当前数据库">2. 验证 OpenWebUI 当前数据库</h3>
<p>为了避免输出密码，只解析连接串中的非敏感部分：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec -i open-webui python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import os
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">from urllib.parse import urlsplit
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">u = urlsplit(os.environ[&#34;DATABASE_URL&#34;])
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Database host configured:&#34;, bool(u.hostname))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Database port:&#34;, u.port)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;TLS required:&#34;, &#34;sslmode=require&#34; in u.query)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>验证重点：</p>
<ul>
<li>Host 已不再指向 VM 本地 PostgreSQL</li>
<li>端口为 5432</li>
<li>已启用 <code>sslmode=require</code></li>
</ul>
<h3 id="3-检查启动日志">3. 检查启动日志</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker logs <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --since 15m <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  open-webui 2&gt;&amp;<span style="color:#ae81ff">1</span> |
</span></span><span style="display:flex;"><span>grep -Ei <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;error|fatal|exception|traceback|permission denied|database|postgres|alembic|ssl|timeout&#39;</span>
</span></span></code></pre></div><p>正常情况下只会看到类似：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
</span></span><span style="display:flex;"><span>INFO [alembic.runtime.migration] Will assume transactional DDL.
</span></span></code></pre></div><p>不应出现：</p>
<ul>
<li>数据库连接失败</li>
<li>密码认证失败</li>
<li>TLS 错误</li>
<li>Schema 权限错误</li>
<li>Alembic 迁移失败</li>
<li>Python Traceback</li>
</ul>
<h2 id="卸载-vm-本地-postgresql">卸载 VM 本地 PostgreSQL</h2>
<p>确认 OpenWebUI 已稳定连接 Azure PostgreSQL 后，可以删除 VM 上的本地 PostgreSQL。</p>
<p>首先确认：</p>
<ul>
<li>OpenWebUI 健康检查正常</li>
<li>数据库连接已切换</li>
<li>Azure PostgreSQL 中存在最新数据</li>
<li>自动备份策略已经启用</li>
<li>本地 PostgreSQL 不再有应用连接</li>
</ul>
<p>停止并删除本地集群：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo pg_dropcluster <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --stop <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  &lt;POSTGRESQL_VERSION&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  main
</span></span></code></pre></div><p>卸载 PostgreSQL 服务端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt-get purge -y <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-&lt;POSTGRESQL_VERSION&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-contrib
</span></span></code></pre></div><p>删除遗留数据和配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo rm -rf <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /etc/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/log/postgresql
</span></span></code></pre></div><p>可以继续保留 PostgreSQL 客户端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgresql-client
</span></span><span style="display:flex;"><span>postgresql-client-common
</span></span></code></pre></div><p>这样仍然可以从 VM 使用 <code>psql</code> 管理 Azure PostgreSQL。</p>
<p>最终检查本地 5432：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> sudo ss -lntp | grep -q <span style="color:#e6db74">&#39;:5432&#39;</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>  sudo ss -lntp | grep <span style="color:#e6db74">&#39;:5432&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;No local PostgreSQL listener&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><h2 id="迁移过程中的经验总结">迁移过程中的经验总结</h2>
<h3 id="1-数据库迁移不等于-openwebui-全部数据迁移">1. 数据库迁移不等于 OpenWebUI 全部数据迁移</h3>
<p>OpenWebUI 除了 PostgreSQL，还可能在 <code>/app/backend/data</code> 中保存：</p>
<ul>
<li>上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>本地状态文件</li>
</ul>
<p>只执行 <code>pg_dump</code> 并不能迁移这些内容。</p>
<h3 id="2-不要只测试端口连通">2. 不要只测试端口连通</h3>
<p><code>nc</code> 成功只能证明 TCP 5432 可达，并不能证明：</p>
<ul>
<li>PostgreSQL 用户可以登录</li>
<li>密码正确</li>
<li>TLS 正常</li>
<li>数据库权限正确</li>
<li>OpenWebUI 使用的驱动兼容</li>
</ul>
<p>应至少完成以下验证：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DNS → TCP → PostgreSQL 登录 → TLS → SQL 查询 → OpenWebUI 实际连接
</span></span></code></pre></div><h3 id="3-数据库-owner-不代表一定拥有-schema-创建权限">3. 数据库 owner 不代表一定拥有 schema 创建权限</h3>
<p>即使应用用户是数据库 owner，也可能遇到：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>permission denied for schema public
</span></span></code></pre></div><p>迁移前应验证应用账号是否具备：</p>
<ul>
<li><code>USAGE</code></li>
<li><code>CREATE</code></li>
<li>表和序列所有权</li>
<li>Alembic DDL 权限</li>
</ul>
<h3 id="4-在线全量备份后必须处理增量写入">4. 在线全量备份后必须处理增量写入</h3>
<p><code>pg_dump</code> 提供的是开始备份时的一致性快照。</p>
<p>备份期间 OpenWebUI 新产生的对话和消息不会自动进入已经完成的 dump。因此在线迁移必须增加以下机制之一：</p>
<ul>
<li>最终停写后重新完整备份</li>
<li>逻辑复制</li>
<li>CDC</li>
<li>应用双写</li>
<li>按业务对象执行增量同步</li>
</ul>
<p>本次采用的是“全量快照 + 活跃对话 UPSERT”。</p>
<h3 id="5-不要在迁移时顺便升级-openwebui">5. 不要在迁移时顺便升级 OpenWebUI</h3>
<p>本次迁移始终保持 OpenWebUI 镜像版本不变。</p>
<p>如果同时升级应用和迁移数据库，一旦发生问题，很难快速判断原因来自：</p>
<ul>
<li>PostgreSQL 版本</li>
<li>数据迁移</li>
<li>Azure 网络</li>
<li>数据库权限</li>
<li>OpenWebUI 版本</li>
<li>Alembic schema 变化</li>
</ul>
<p>更稳妥的做法是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>先迁移数据库并稳定运行
</span></span><span style="display:flex;"><span>          ↓
</span></span><span style="display:flex;"><span>再单独升级 OpenWebUI
</span></span></code></pre></div><h2 id="这算不算真正的零停机">这算不算真正的零停机？</h2>
<p>从用户体验看，这次迁移接近零停机：</p>
<ul>
<li>在线完成全量备份</li>
<li>在线恢复目标数据库</li>
<li>在线完成数据验证</li>
<li>在线同步活跃对话</li>
<li>仅在最后重建了一次 OpenWebUI 容器</li>
</ul>
<p>但如果采用严格工程定义，真正的零停机还需要满足：</p>
<ul>
<li>任意时刻都能接受新连接</li>
<li>最后时刻写入绝不丢失</li>
<li>没有单实例容器重启窗口</li>
<li>客户端连接完全不中断</li>
<li>具备自动流量切换能力</li>
</ul>
<p>更大规模的 OpenWebUI 部署可以考虑：</p>
<ul>
<li>PostgreSQL 逻辑复制</li>
<li>Azure Database Migration Service</li>
<li>CDC 增量迁移</li>
<li>应用双写</li>
<li>蓝绿部署</li>
<li>多个 OpenWebUI 实例</li>
<li>反向代理健康检查</li>
<li>连接排空和流量切换</li>
</ul>
<p>对于单 VM、单 OpenWebUI 容器和中小规模数据库，本次采用的方案在复杂度、风险和停机时间之间取得了较好的平衡。</p>
<h2 id="迁移后的日常运维">迁移后的日常运维</h2>
<p>启动 OpenWebUI：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  up -d
</span></span></code></pre></div><p>查看状态：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  ps
</span></span></code></pre></div><p>查看日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  logs -f --tail<span style="color:#f92672">=</span><span style="color:#ae81ff">200</span>
</span></span></code></pre></div><p>健康检查：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -sS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o /dev/null <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -w <span style="color:#e6db74">&#39;HTTP %{http_code}\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  http://127.0.0.1:8080/health
</span></span></code></pre></div><p>日常还应重点监控 Azure PostgreSQL 的：</p>
<ul>
<li>CPU 使用率</li>
<li>内存使用情况</li>
<li>存储空间</li>
<li>活跃连接数</li>
<li>失败连接数</li>
<li>查询延迟</li>
<li>自动备份状态</li>
<li>维护事件</li>
<li>Private Endpoint 状态</li>
</ul>
<h2 id="总结">总结</h2>
<p>这次迁移将 OpenWebUI 的数据库从 Azure VM 自建 PostgreSQL 平滑迁移到了 Azure Database for PostgreSQL Flexible Server。</p>
<p>最终实现了：</p>
<ul>
<li>OpenWebUI 数据库由 Azure 托管</li>
<li>通过 Private Endpoint 私网访问</li>
<li>数据库连接启用 TLS</li>
<li>自动备份和时间点恢复</li>
<li>OpenWebUI 本地文件持久化</li>
<li>VM 本地 PostgreSQL 完全下线</li>
<li>本地 5432 端口关闭</li>
<li>全量迁移期间应用持续在线</li>
<li>最终切换对用户影响极低</li>
</ul>
<p>本次最关键的经验是：</p>

<blockquote>
  <p>对 OpenWebUI 进行低停机数据库迁移，不应只关注 <code>pg_dump</code> 和 <code>pg_restore</code>，还必须同时考虑增量写入、Private Endpoint、TLS、数据库权限、Alembic 迁移以及 <code>/app/backend/data</code> 中的本地文件。</p>

</blockquote>
<p>通过“<strong>在线全量迁移、目标端预验证、活跃数据增量同步、快速容器切换</strong>”，可以在不引入复杂 CDC 平台的情况下，将中小规模 OpenWebUI 平滑迁移到 Azure 托管 PostgreSQL。</p>
]]></content:encoded></item></channel></rss>