訂閱以接收新文章的通知:

構建 D1:全球資料庫

2024-04-01

閱讀時間:8 分鐘
本貼文還提供以下語言版本:EnglishFrançaisDeutsch日本語한국어Español简体中文

建置 Worker 應用程式的開發人員專注於他們正在建立的內容,而不是所需的基礎架構,並受益於 Cloudflare 網路的全球覆蓋范圍。許多應用程式需要持久性資料,從個人專案到業務關鍵型工作負載。Workers 提供適合開發人員需求的各種資料庫和儲存選項,例如鍵值儲存體和物件儲存體。,

關聯式資料庫是當今許多應用程式的支柱。Cloudflare 的關聯式資料庫補充產品 D1 現已正式上市。從 2022 年底推出 Alpha 版本,到 2024 年 4 月正式上市,這一段旅程的重點是讓開發人員能夠熟練使用關聯式資料和 SQL 來建立生產工作負載。

D1 是什麼?

D1 是 Cloudflare 內建的無伺服器關聯式資料庫。對於 Worker 應用程式,D1 利用 SQLite 的 SQL 方言和開發人員工具整合(包括 Drizzle ORM 之類的物件關聯式對應程式 (ORM),提供了 SQL 的表現力。D1 可透過 WorkersHTTP API 存取。

無伺服器意味著無需佈建、透過 Time Travel 進行預設災難復原以及基於使用情況的定價。D1 包含慷慨的免費方案,允許開發人員試用 D1,然後再升級到生產階段。

如何讓資料全球化?

D1 GA 版專注於可靠性和開發人員體驗。現在,我們計劃擴展 D1,以更好地支援全球分散式應用程式。

在 Workers 模型中,傳入請求會在最近的資料中心中叫用無伺服器執行。Worker 應用程式可以根據使用者請求在全球擴展。然而,應用程式資料仍然儲存在集中式資料庫中,並且全球使用者流量必須考慮到與資料位置的存取往返。例如,現在的 D1 資料庫駐留在單一位置。

Workers 支援 Smart Placement 來考慮經常存取的資料位置。Smart Placement 叫用更接近集中式後端服務(例如資料庫)的 Worker,以減少延遲並提高應用程式效能。我們已經解決了全球化應用程式中的 Workers 放置問題,但還需要解決資料放置問題。

那麼問題來了,D1 作為 Cloudflare 內建的資料庫解決方案,如何更好的支援全球化應用程式的資料放置呢?答案是非同步讀取複製。

什麼是非同步讀取複製?

在基於伺服器的資料庫管理系統(例如 Postgres、MySQL、SQL Server 或 Oracle)中,讀取複本是一個單獨的資料庫伺服器,可作為主要資料庫伺服器的接近最新唯讀複本。管理員透過從主要伺服器的快照啟動新伺服器,並將主要伺服器設定為將更新非同步傳送到複本伺服器,來建立讀取複本。由於更新是非同步的,讀取複本可能落後於主要伺服器的當前狀態。主要伺服器和複本伺服器之間的差異稱為複本滯後。可以有多個讀取複本。

非同步讀取複製是一種經過時間驗證的提高資料庫效能的解決方案:

  • 可以透過在多個複本之間分配負載來提高輸送量。

  • 當複本靠近進行查詢的使用者時,可以減少查詢延遲。

請注意,某些資料庫系統還提供同步複製。在同步複製系統中,寫入必須等到所有複本都確認寫入為止。同步複製系統的執行速度只能與最慢的複本一樣快,並且只要其中一個複本發生故障,就會陷入停滯。如果我們試圖在全球範圍內提高效能,我們希望盡可能避免同步複製!

一致性模型和讀取複本

大多數資料庫系統提供讀取已提交快照隔離可串聯化一致性模型,具體取決於其設定。例如,Postgres 預設為讀取已提交,但可以設定為使用更強的模型。SQLite 提供 WAL 模式的快照隔離。快照隔離或可串聯化等更強的模式更容易進行程式設計,因為它們限制了允許的系統並行場景以及程式設計師需要擔心的並行競爭條件類型。

讀取複本是獨立更新的,因此每個複本的內容在任何時刻都可能不同。如果所有查詢都傳送到同一台伺服器,無論是主要伺服器還是讀取複本,則不管底層資料庫提供什麼一致性模型,結果都應該保持一致。如果您使用讀取複本,結果可能並不是最新。

在具有讀取複本的伺服器型資料庫中,對一個工作階段中的所有查詢始終使用相同伺服器非常重要。如果您在同一工作階段中切換不同的讀取複本,則會損害您的應用程式提供的一致性模型,這可能會違反您對資料庫行為方式的假設,並導致您的應用程式返回不正確的結果!

範例例如,有兩個複本 A 和 B,複本 A 落後主要資料庫 100 毫秒,複本 B 落後主要資料庫 2 秒。假設使用者希望:

  1. 執行查詢 11a. 根據查詢 1 結果進行一些計算

  2. 根據 (1a) 中的計算結果執行查詢 2

在時間 t=10s 時,查詢 1 前往複本 A 並傳回結果。查詢 1 查看主要資料庫在 t=9.9s 時的情況。假設計算需要 500 毫秒,因此在 t=10.5s 時,查詢 2 轉到複本 B。請記住,複本 B 落後主要資料庫 2 秒,因此在 t=10.5 秒時,查詢 2 會看到資料庫 t =8.5s 時的樣子。就該應用程式而言,查詢 2 的結果看起來資料庫已經時間倒退了!

正式地說,這是讀取已提交一致性,因為您的查詢只會看到已提交的資料,但沒有其他保證——甚至您不能讀取自己的寫入。雖然讀取已提交是一種有效的一致性模型,但很難推斷讀取已提交模型允許的所有可能的競爭條件,這導致難以正確編寫應用程式。

D1 的一致性模型與讀取複本

預設情況下,D1 提供 SQLite 提供的快照隔離

快照隔離是一種熟悉的一致性模型,大多數開發人員都認為它易於使用。我們確保 D1 資料庫最多只有一個作用中複本,並將所有 HTTP 請求路由到該單一資料庫,從而在 D1 中實作這種一致性模型。雖然確保 D1 資料庫最多有一個作用中複本是一個棘手的分散式系統問題,但我們透過使用 Durable Objects 建立 D1 解決了這個問題。Durable Objects 保證全球唯一性,因此一旦我們依賴 Durable Objects,路由 HTTP 請求就很容易:只需將它們傳送到 D1 Durable Object 即可。

如果您有資料庫的多個作用中複本,則此技巧不起作用,因為沒有 100% 可靠的方法來查看一般傳入 HTTP 請求並將其 100% 路由到相同複本。遺憾的是,正如我們在上一節的範例中看到的,如果我們不能始終將相關請求路由到同一個複本,那麼我們可以提供的最佳一致性模型就是讀取已提交。

鑑於不可能一致地路由到特定複本,另一種方法是將請求路由到任何複本,並確保所選複本根據對程式設計師「有意義」的一致性模型回應請求。如果我們願意在請求中包含 Lamport 時間戳記,我們可以使用任何複本來實現順序一致性。順序一致性模型具有重要的屬性,例如「讀取我自己的寫入」和「寫入跟隨讀取」以及寫入的總順序。寫入的總順序意味著每個複本都會看到交易以相同的順序提交,這正是我們希望交易系統中發生的行為。順序一致性伴隨著一個警告,即系統中的任何單一實體都可能隨意過時,但這個警告對我們來說是一個功能,因為它允許我們在設計 API 時考慮複本滯後。

這個想法是,如果 D1 針對每個資料庫查詢為應用程式提供一個 Lamport 時間戳記,並且這些應用程式告訴 D1 它們看到的最後一個 Lamport 時間戳記,我們可以讓每個複本確定如何根據順序一致性模型使查詢工作。

實現複本順序一致性的一種強大但簡單的方法是:

  • 將 Lamport 時間戳記與對資料庫的每個請求相關聯。單調遞增的提交權杖可以很好地實現這一點。

  • 將所有寫入查詢傳送到主要資料庫以確保寫入的總順序。

  • 將讀取查詢傳送到任何複本,但讓複本延遲為查詢提供服務,直到複本從主要資料庫接收到晚於查詢中的 Lamport 時間戳記的更新。

這種實作的優點在於,在讀取繁重的工作負載始終前往同一個複本的常見情況下速度很快,而且即使請求路由到不同的複本也能正常工作。

搶先預覽:透過 Sessions 將讀取複製引入 D1

為了將讀取複製引入 D1,我們將使用新概念擴展 D1 API:Sessions。一個 Session 封裝了代表應用程式的一個邏輯工作階段的所有查詢。例如,Session 可能代表來自特定 Web 瀏覽器的所有請求或來自行動 App 的所有請求。如果您使用 Sessions,您的查詢將使用對您的請求最有意義的 D1 資料庫複本,可能是主要資料庫,也可能是附近的複本。D1 的 Sessions 實作將確保 Session 中所有查詢的順序一致性。

由於 Sessions API 改變了 D1 的一致性模型,開發人員必須選擇使用新的 API。現有的 D1 API 方法保持不變,仍將具有與先前相同的快照隔離一致性模型。但是,只有使用新的 Sessions API 進行的查詢才會使用複本。

以下是 D1 Sessions API 的範例:

D1 的 Sessions 實作利用了提交權杖。提交權杖標識對資料庫的特定已提交查詢。在工作階段中,D1 將使用提交權杖來確保查詢是按順序排序的。在上面的範例中,D1 工作階段確保新訂單的「SELECT COUNT(*)」查詢發生在「INSERT」之後,即使我們在等待之間切換複本也是如此。

export default {
  async fetch(request: Request, env: Env) {
    // When we create a D1 Session, we can continue where we left off
    // from a previous Session if we have that Session's last commit
    // token.  This Worker will return the commit token back to the
    // browser, so that it can send it back on the next request to
    // continue the Session.
    //
    // If we don't have a commit token, make the first query in this
    // session an "unconditional" query that will use the state of the
    // database at whatever replica we land on.
    const token = request.headers.get('x-d1-token') ?? 'first-unconditional'
    const session = env.DB.withSession(token)


    // Use this Session for all our Workers' routes.
    const response = await handleRequest(request, session)


    if (response.status === 200) {
      // Set the token so we can continue the Session in another request.
      response.headers.set('x-d1-token', session.latestCommitToken)
    }
    return response
  }
}


async function handleRequest(request: Request, session: D1DatabaseSession) {
  const { pathname } = new URL(request.url)


  if (pathname === '/api/orders/list') {
    // This statement is a read query, so it will execute on any
    // replica that has a commit equal or later than `token` we used
    // to create the Session.
    const { results } = await session.prepare('SELECT * FROM Orders').all()


    return Response.json(results)
  } else if (pathname === '/api/orders/add') {
    const order = await request.json<Order>()


    // This statement is a write query, so D1 will send the query to
    // the primary, which always has the latest commit token.
    await session
      .prepare('INSERT INTO Orders VALUES (?, ?, ?)')
      .bind(order.orderName, order.customer, order.value)
      .run()


    // In order for the application to be correct, this SELECT
    // statement must see the results of the INSERT statement above.
    // The Session API keeps track of commit tokens for queries
    // within the session and will ensure that we won't execute this
    // query until whatever replica we're using has seen the results
    // of the INSERT.
    const { results } = await session
      .prepare('SELECT COUNT(*) FROM Orders')
      .all()


    return Response.json(results)
  }


  return new Response('Not found', { status: 404 })
}

關於如何在 Workers fetch 處理常式中啟動工作階段,有多種選項。  db.withSession(<condition>) 接受下列引數:

condition 引數

condition argument

Behavior

<commit_token>

(1) starts Session as of given commit token

(2) subsequent queries have sequential consistency

first-unconditional

(1) if the first query is read, read whatever current replica has and use the commit token of that read as the basis for subsequent queries.  If the first query is a write, forward the query to the primary and use the commit token of the write as the basis for subsequent queries.

(2) subsequent queries have sequential consistency

first-primary

(1) runs first query, read or write, against the primary

(2) subsequent queries have sequential consistency

null or missing argument

treated like first-unconditional 

行為

<commit_token>

(1) 從給定的提交權杖開始工作階段

(2) 後續查詢具有順序一致性

first-unconditional

(1) 如果第一個查詢是讀取,則讀取目前複本的所有內容,並使用該讀取的提交權杖作為後續查詢的基礎。  如果第一個查詢是寫入,則將查詢轉送到主要伺服器,並使用寫入的提交權杖作為後續查詢的基礎。

(2) 後續查詢具有順序一致性

first-primary

(1) 針對主要伺服器執行第一個查詢(讀取或寫入)

(2) 後續查詢具有順序一致性

npx wrangler d1 create northwind-traders

# omit --remote to run on a local database for development
npx wrangler d1 execute northwind-traders --remote --file=./schema.sql

npx wrangler d1 execute northwind-traders --remote --file=./data.sql

null 或缺少引數

# database schema & data
npx wrangler d1 export northwind-traders --remote --output=./database.sql

# single table schema & data
npx wrangler d1 export northwind-traders --remote --table='Employee' --output=./table.sql

# database schema only
npx wrangler d1 export <database_name> --remote --output=./database-schema.sql --no-data=true

視為 first-unconditional 

# To find top 10 queries by average execution time:
npx wrangler d1 insights <database_name> --sort-type=avg --sort-by=time --count=10

透過雙向傳遞工作階段最後一個查詢的提交權杖並使用它來啟動新工作階段,可以使工作階段跨越多個請求。這使得單一使用者代理程式(例如 Web App 或行動 App)能夠確保使用者看到的所有查詢都順序一致。

D1 的讀取複製將是內建的,不會產生額外的使用或儲存成本,並且不需要複本設定。Cloudflare 將監控應用程式的 D1 流量並自動建立資料庫複本,以將使用者流量分散到更靠近使用者的位置的多個伺服器。與我們的無伺服器模型一樣,D1 開發人員不必擔心複本設定和管理,而應專注於設計應用程式以進行複製和資料一致性權衡。

我們正在積極處理全球讀取複製工作並實現上述提議(在我們的開發人員 Discord 上的 #d1 頻道中分享意見反應)。而在目前,D1 GA 版中已經包含了一些令人興奮的新功能。

查看 D1 GA 版

自 2023 年 10 月 D1 公開測試以來,我們一直注重關鍵服務所需的 D1 可靠性、可擴展性和開發人員體驗。我們投資了多項新功能,使開發人員能夠使用 D1 更快地建立和偵錯應用程式。

使用更大的資料庫進行更大的構建我們聽取了開發人員對更大資料庫的需求。D1 現在支援高達 10GB 的資料庫,其中 Workers Paid 方案支援 50K 資料庫。藉助 D1 的水平擴展,應用程式可以對「每個企業實體一個資料庫」用例進行建模。自 Beta 版以來,新的 D1 資料庫在給定時間內處理的請求比 D1 Alpha 版資料庫多 40 倍。

匯入和匯出大量資料開發人員會出於多種原因匯入和匯出資料:

  • 不同資料庫系統之間的資料庫遷移測試

  • 用於本地開發或測試的資料複本

  • 手動備份以滿足合規性等自訂要求

您之前可以針對 D1 執行 SQL 檔案,但我們正在改進 wrangler d1 execute –file=<filename> 以確保大型匯入是原子操作,永遠不會讓您的資料庫處於不完全狀態。 wrangler d1 execute 在也預設為本機優先,以保護您的遠端生產資料庫。

若要匯入我們的 Northwind Traders 示範資料庫,您可以下載結構描述資料並執行 SQL 檔案。

可以使用以下命令將 D1 資料庫資料和結構描述、僅結構描述或僅資料匯出到 SQL 檔案:

偵錯查詢效能瞭解 SQL 查詢效能和偵錯慢速查詢是生產工作負載的關鍵步驟。我們加入了實驗性的 wrangler d1 insights, 以幫助開發人員分析也可透過 GraphQL API 獲得的查詢效能指標。

開發人員工具各種社群開發人員專案支援 D1。新加入的內容包括 5.12.0 版本的 Prisma ORM,其現在支援 Workers 和 D1。

後續步驟

GA 版現在提供的功能和我們的全球讀取複製設計只是滿足開發人員應用程式 SQL 資料庫需求的開始。如果您尚未使用 D1,您可以立即開始使用,請造訪 D1 的開發人員文件以激發一些想法,或加入我們的開發人員 Discord 上的 #d1 頻道,與其他 D1 開發人員和我們的產品工程團隊進行交流。

我們保護整個企業網路,協助客戶有效地建置網際網路規模的應用程式,加速任何網站或網際網路應用程式抵禦 DDoS 攻擊,阻止駭客入侵,並且可以協助您實現 Zero Trust

從任何裝置造訪 1.1.1.1,即可開始使用我們的免費應用程式,讓您的網際網路更快速、更安全。

若要進一步瞭解我們協助打造更好的網際網路的使命,請從這裡開始。如果您正在尋找新的職業方向,請查看我們的職缺
Developer WeekDevelopersDeveloper PlatformD1Database

在 X 上進行關注

Vy Ton|@vaiton13
Cloudflare|@cloudflare

相關貼文

2024年10月31日 下午1:00

Moving Baselime from AWS to Cloudflare: simpler architecture, improved performance, over 80% lower cloud costs

Post-acquisition, we migrated Baselime from AWS to the Cloudflare Developer Platform and in the process, we improved query times, simplified data ingestion, and now handle far more events, all while cutting costs. Here’s how we built a modern, high-performing observability platform on Cloudflare’s network. ...