譯自 The Developer’s Guide to Database Proxies: When to Use Them and How to Create One,作者 Alex Pliutau。
想象一個高度依賴資料的複雜分布式系統,其中每個微服務或團隊都單獨連接配接到資料庫(可以是共享資料庫或特定/隔離的資料庫)。如此複雜的平台需要集中監控、查詢驗證、警報、自定義分片以及更好的安全性等等。雖然您可以從資料庫伺服器獲得很多這些功能,但實施資料庫代理可能是一個更好的方法(如果您準備投資)。
使用資料庫代理的主要優勢在于它将資料庫拓撲與應用程式層隔離開來,是以開發人員無需了解資料層的叢集、節點和内部結構(當然在一定程度上)。
資料庫代理用例
讓我們深入了解資料庫代理如何賦能您的開發團隊、增強安全性并優化資料庫性能的各種方式。
- 攔截來自應用程式的 SQL 查詢 并将其動态路由到正确的資料庫/表(例如自定義分片)。Figma正在做 exactly that使用他們的内部 Postgres 代理。
- 解析/分析/驗證來自開發人員的 SQL 查詢并使用附加資訊豐富響應。這可能有助于告訴應用程式哪些表将被棄用。
- 可擴充性和架構更改不會影響應用程式。 平台/資料庫團隊可以獨立更改架構,而無需重寫數百個微服務。能夠透明地添加或删除資料庫叢集中的節點,而無需重新配置或重新啟動應用程式。
- 執行安全政策并執行身份驗證和授權檢查,以確定隻有授權的用戶端才能通路資料庫。也可以禁止直接通路資料庫。
- 提高資料庫通信的性能,通過集中管理連接配接池、利用緩存技術等。
- 集中式可觀察性。 當應用程式使用已棄用的表時收到通知,等等。
何時使用資料庫代理
并非所有系統都需要資料庫代理,尤其是在早期階段。以下是一般準則,說明何時可能需要它:
- 您有多個由不同學科劃分的開發團隊:例如多個後端團隊、資料工程團隊。
- 您有一個平台/資料庫團隊來擁有它。雖然其他團隊也可以擁有它。
- 您的系統是分布式的,并且您維護着許多微服務和許多資料庫。
- 您的系統資料量很大。
- 您需要更好的安全性和可觀察性。
使用資料庫代理的成本
使用資料庫代理确實會帶來成本:
- 資料庫代理是基礎設施中的一個新元素,它本身具有複雜性。
- 可能是單點故障,是以必須非常穩定且經過實戰檢驗。
- 額外的網絡延遲。
資料庫代理類型
您可以通過幾種方式部署資料庫代理:
- 自定義代理服務(下面我将提供一個簡單的 Go 示例)
- 托管雲解決方案,例如 Amazon RDS Proxy
- Sidecars,例如 Cyral
- 商業和開源産品,例如 ProxySQL,或dbpack
使用 Go 編寫自定義資料庫代理服務
現在,我們将使用 Go 實作自己的 MySQL 代理。請記住,這隻是一個解釋想法的實驗。
我們的代理将解決一個非常簡單的用例:攔截 SQL 查詢并在比對模式時重寫表名。
-- Application-generated query
SELECT * FROM orders_v1;
-- Rewritten query
SELECT * FROM orders_v2;
實作分為兩個部分:
- 将查詢從用戶端路由到 MySQL 伺服器的基本代理。
- SQL 解析器,具有一些在發送查詢之前操作查詢的邏輯。
您可以在此 Github 存儲庫中檢視完整的源代碼。
從用戶端到 MySQL 伺服器的 TCP 代理
我們的 TCP 代理采用非常簡單的方法實作,絕對不适合生産環境,但足以示範 TCP 傳輸的工作原理:
- 建立一個代理 TCP 伺服器
- 接受連接配接
- 建立到 MySQL 的 TCP 連接配接
- 使用管道将位元組流從用戶端代理到 MySQL 伺服器,反之亦然
main.go
package main
import (
"fmt"
"io"
"log"
"net"
"os"
)
func main() {
// proxy listens on port 3307
proxy, err := net.Listen("tcp", ":3307")
if err != nil {
log.Fatalf("failed to start proxy: %s", err.Error())
}
for {
conn, err := proxy.Accept()
log.Printf("new connection: %s", conn.RemoteAddr())
if err != nil {
log.Fatalf("failed to accept connection: %s", err.Error())
}
go transport(conn)
}
}
func transport(conn net.Conn) {
defer conn.Close()
mysqlAddr := fmt.Sprintf("%s:%s", os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_PORT"))
mysqlConn, err := net.Dial("tcp", mysqlAddr)
if err != nil {
log.Printf("failed to connect to mysql: %s", err.Error())
return
}
readChan := make(chan int64)
writeChan := make(chan int64)
var readBytes, writeBytes int64
// from proxy to mysql
go pipe(mysqlConn, conn, true)
// from mysql to proxy
go pipe(conn, mysqlConn, false)
readBytes = <-readChan
writeBytes = <-writeChan
log.Printf("connection closed. read bytes: %d, write bytes: %d", readBytes, writeBytes)
}
func pipe(dst, src net.Conn, send bool) {
if send {
intercept(src, dst)
}
_, err := io.Copy(dst, src)
if err != nil {
log.Printf("connection error: %s", err.Error())
}
}
func intercept(src, dst net.Conn) {
buffer := make([]byte, 4096)
for {
n, _ := src.Read(buffer)
dst.Write(buffer[0:n])
}
}
函數說明:
- transport - 處理 TCP 連接配接,雙向傳輸位元組。
- pipe - 傳遞位元組,如果是 proxy → mysql,它還會調用 intercept 來處理查詢。
- intercept - 我們将在之後實作它來解析查詢。
Dockerfile
FROM golang:1.22 as builder
WORKDIR /
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o proxy main.go
FROM alpine:latest
COPY --from=builder /proxy .
EXPOSE 3307
CMD ["./proxy"]
docker-compose.yaml
services:
proxy:
restart: always
build:
context: .
ports:
- 3307:3307
environment:
- MYSQL_HOST=mysql
- MYSQL_PORT=3306
links:
- mysql
mysql:
restart: always
image: mysql:5.7
platform: linux/amd64
ports:
- 3306:3306
environment:
- MYSQL_ROOT_PASSWORD=root
command: --init-file /data/application/init.sql
volumes:
- ./init.sql:/data/application/init.sql
init.sql
CREATE DATABASE IF NOT EXISTS packagemain;
CREATE TABLE IF NOT EXISTS packagemain.orders_v2 (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
) ENGINE=InnoDB;
INSERT INTO packagemain.orders_v2 (name) VALUES ('order1');
SQL 解析
我們的 intercept 函數已經可以擷取位元組包。了解 MySQL 包的結構很有幫助。我不會深入細節,但你可以 在這裡閱讀。
在我們的 intercept 函數中,我們執行以下操作:
- 查找 COM_QUERY 用戶端指令,其數字代碼為 3。
- 擷取原始查詢。
- 進行非常基本的表重命名。
有一個很棒的包 sqlparser 來自 YouTube 的 Vitess 項目,我們可以用它來解析 SQL 查詢。但是,為了簡化示範,我們将使用字元串比對和替換。
main.go
const COM_QUERY = byte(0x03)
func intercept(src, dst net.Conn) {
buffer := make([]byte, 4096)
for {
n, _ := src.Read(buffer)
if n > 5 {
switch buffer[4] {
case COM_QUERY:
clientQuery := string(buffer[5:n])
newQuery := rewriteQuery(clientQuery)
fmt.Printf("client query: %s\n", clientQuery)
fmt.Printf("server query: %s\n", newQuery)
writeModifiedPacket(dst, buffer[:5], newQuery)
continue
}
}
dst.Write(buffer[0:n])
}
}
func rewriteQuery(query string) string {
return strings.NewReplacer("from orders_v1", "from orders_v2").Replace(strings.ToLower(query))
}
func writeModifiedPacket(dst net.Conn, header []byte, query string) {
newBuffer := make([]byte, 5+len(query))
copy(newBuffer, header)
copy(newBuffer[5:], []byte(query))
dst.Write(newBuffer)
}
運作代理并連接配接到它
在這裡,我們連接配接到運作在端口 3307 上的代理,而不是 MySQL 伺服器本身(端口 3306)。如你所見,我們可以使用正常的 MySQL 用戶端,這簡化了代理的使用。
這意味着 orders_v1 表被重定向到 orders_v2。代理日志:
client query: select * from orders_v1;
server query: select * from orders_v2;
結論
總之,資料庫代理在應用程式和底層資料庫之間提供了一個強大的抽象層。它通過隔離資料庫複雜性來簡化開發,使資料庫團隊能夠獨立進行模式更改,并通過集中式通路控制來增強安全性。雖然存在基礎設施開銷和潛在延遲等額外成本,但對于具有多個團隊和資料密集型需求的複雜分布式系統,資料庫代理可能是一項值得的投資。