团队接手了一个有数年历史的PHP看板(Kanban)项目。其核心问题在于,所有状态更新都依赖于前端定时发起的AJAX轮询。在看板卡片数量增多、团队协作频繁时,服务器压力巨大,且用户体验延迟明显。技术债的另一个体现是,整个系统是一个巨大的单体应用,任何微小的改动都需要完整的回归测试和部署,效率低下。彻底重写风险太高,周期也无法接受。我们最终选择的方案是“绞杀者模式(Strangler Fig Pattern)”,逐步用Go重写核心的高并发模块,并利用CircleCI保障这个混合架构的稳定交付。
第一步是引入一个代理层。这个代理将是所有流量的入口,负责将请求路由到新的Go服务或旧的PHP单体。在真实项目中,这个代理本身必须是高性能且高可用的。Go的net/http/httputil
包提供了构建反向代理的完美工具。
// file: cmd/proxy/main.go
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
)
// newProxy a helper function to create a new reverse proxy.
func newProxy(targetHost string) (*httputil.ReverseProxy, error) {
url, err := url.Parse(targetHost)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(url)
// 自定义Director,可以在这里修改请求头等
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// 伪造一些请求头,让后端知道请求来自代理
req.Header.Set("X-Proxy", "StranglerFigProxy")
log.Printf("Routing request for %s to %s", req.URL.Path, targetHost)
}
// 自定义错误处理
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
log.Printf("Proxy error: %v", err)
rw.WriteHeader(http.StatusBadGateway)
}
return proxy, nil
}
// route decides which service to route the request to.
func route(r *http.Request, phpTarget, goTarget *httputil.ReverseProxy) {
// 我们的绞杀策略:
// 1. 新的、高性能的实时API路由到Go服务
// 2. 所有其他旧API和页面请求路由到PHP单体
if strings.HasPrefix(r.URL.Path, "/api/v2/realtime") || strings.HasPrefix(r.URL.Path, "/api/v2/cards") {
log.Printf("Routing to Go service for path: %s", r.URL.Path)
goTarget.ServeHTTP(w, r)
} else {
log.Printf("Routing to legacy PHP service for path: %s", r.URL.Path)
phpTarget.ServeHTTP(w, r)
}
}
func main() {
// 从环境变量获取后端服务地址,这是生产实践的基础
phpServiceHost := os.Getenv("PHP_SERVICE_HOST")
if phpServiceHost == "" {
phpServiceHost = "http://localhost:8000" // 本地开发默认值
}
goServiceHost := os.Getenv("GO_SERVICE_HOST")
if goServiceHost == "" {
goServiceHost = "http://localhost:8081" // 本地开发默认值
}
log.Printf("Proxy started. PHP Target: %s, Go Target: %s", phpServiceHost, goServiceHost)
phpProxy, err := newProxy(phpServiceHost)
if err != nil {
log.Fatalf("Failed to create PHP proxy: %v", err)
}
goProxy, err := newProxy(goServiceHost)
if err != nil {
log.Fatalf("Failed to create Go proxy: %v", err)
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
route(r, phpProxy, goProxy)
})
server := &http.Server{
Addr: ":8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Proxy server failed to start: %v", err)
}
}
这个代理非常基础,但包含了生产环境的关键要素:通过环境变量配置后端地址、自定义Director
来修改请求、以及基础的日志和错误处理。路由逻辑目前是基于URL前缀的硬编码,这在迁移初期是可接受的。
我们的第一个目标是替换AJAX轮询。我们将在Go服务中实现一个WebSocket端点,用于实时推送看板的变更。但这里有一个核心问题:当用户通过旧的PHP API(例如,POST /api/v1/cards/1/update
)修改了一张卡片,新的Go WebSocket服务如何得知这个变更并通知所有客户端?
一个常见的错误是让Go服务去轮询PHP应用的数据库。这只是把压力从客户端转移到了内部网络,没有解决根本问题。正确的做法是引入一个轻量级的消息中间件,如Redis Pub/Sub。当PHP应用完成数据库写入后,它会向一个Redis频道发布一条消息。Go服务则订阅这个频道。
这是PHP端修改后的控制器逻辑(以Laravel框架为例):
// file: app/Http/Controllers/CardController.php (Legacy PHP App)
namespace App\Http\Controllers;
use App\Models\Card;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Log;
class CardController extends Controller
{
public function update(Request $request, $id)
{
// ... 输入验证逻辑 ...
$card = Card::findOrFail($id);
// 假设这里是复杂的业务逻辑和数据库更新
// $card->title = $request->input('title');
// $card->description = $request->input('description');
// $card->save();
try {
// 这是关键步骤:在数据库事务成功后发布消息
// 消息体应该包含足够的信息,让Go服务能够构建出推送内容
$payload = json_encode([
'event_type' => 'card_updated',
'timestamp' => time(),
'data' => [
'card_id' => $card->id,
'board_id' => $card->board_id,
'column_id' => $card->column_id,
// ... 其他需要实时同步的字段
]
]);
// 'kanban_events' 是我们约定的频道名
Redis::publish('kanban_events', $payload);
} catch (\Exception $e) {
// 这里的坑在于,如果Redis发布失败,前端将不会收到实时更新
// 生产环境中需要有重试机制或备用通知方案
Log::error('Failed to publish card update event to Redis: ' . $e->getMessage());
}
return response()->json($card);
}
}
现在,Go服务需要实现WebSocket服务器并订阅Redis频道。我们使用gorilla/websocket
库来处理WebSocket连接,并使用go-redis/redis
库来与Redis交互。
// file: internal/realtime/hub.go (New Go Service)
package realtime
import (
"context"
"encoding/json"
"log"
"sync"
"github.com/go-redis/redis/v8"
"github.com/gorilla/websocket"
)
// Event represents a message received from Redis.
type Event struct {
EventType string `json:"event_type"`
Timestamp int64 `json:"timestamp"`
Data json.RawMessage `json:"data"`
}
// Hub maintains the set of active clients and broadcasts messages to the clients.
type Hub struct {
clients map[*websocket.Conn]bool
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
mu sync.Mutex
redisSub *redis.PubSub
ctx context.Context
}
func NewHub(ctx context.Context, rdb *redis.Client) *Hub {
hub := &Hub{
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
clients: make(map[*websocket.Conn]bool),
redisSub: rdb.Subscribe(ctx, "kanban_events"),
ctx: ctx,
}
// 确保在创建时就检查订阅是否成功
if _, err := hub.redisSub.Receive(ctx); err != nil {
log.Fatalf("Failed to subscribe to Redis channel 'kanban_events': %v", err)
}
return hub
}
func (h *Hub) Run() {
go h.listenToRedis()
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
log.Println("Client registered")
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.Close()
}
h.mu.Unlock()
log.Println("Client unregistered")
case message := <-h.broadcast:
h.mu.Lock()
for client := range h.clients {
err := client.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("Error writing to client: %v", err)
client.Close()
delete(h.clients, client)
}
}
h.mu.Unlock()
}
}
}
// listenToRedis listens for messages on the subscribed Redis channel.
func (h *Hub) listenToRedis() {
ch := h.redisSub.Channel()
for {
select {
case <-h.ctx.Done():
log.Println("Context cancelled, stopping Redis listener.")
h.redisSub.Close()
return
case msg := <-ch:
log.Printf("Received message from Redis: %s", msg.Payload)
// 直接将从Redis收到的消息转发给广播channel
h.broadcast <- []byte(msg.Payload)
}
}
}
这个Hub
结构体是实时通信的核心。它管理所有连接的WebSocket客户端,并有一个独立的goroutine (listenToRedis
) 专门监听Redis消息,一旦收到消息就将其广播给所有客户端。这种设计将外部事件源(Redis)和客户端通信(WebSocket)解耦。前端现在可以连接到Go服务的/api/v2/realtime
端点,取代旧的轮询逻辑。这就是一个典型的Headless UI架构:前端UI不关心后端是PHP还是Go,它只消费统一的API和事件流。
接下来,我们需要一个健壮的CI/CD流程来部署这个混合系统。CircleCI的Workflow功能非常适合这种多组件项目。
graph TD A(Start) --> B{checkout}; B --> C[build_and_test_go]; B --> D[build_and_test_php]; C --> E{docker_build_push_go}; D --> F{docker_build_push_php}; E --> G[deploy_to_staging]; F --> G[deploy_to_staging]; G --> H{approve_production_deploy}; H --> I[deploy_to_production];
这是我们的CircleCI工作流示意图。Go服务和PHP应用可以并行构建和测试,然后分别构建Docker镜像,最后统一部署。
下面是.circleci/config.yml
的一个关键部分,展示了如何定义这个工作流:
# file: .circleci/config.yml
version: 2.1
orbs:
docker: circleci/[email protected]
aws-cli: circleci/aws-[email protected]
# ... 其他可能用到的orbs
jobs:
build_and_test_go:
docker:
- image: cimg/go:1.19
working_directory: ~/repo/go-service
steps:
- checkout:
path: ~/repo
- run: go mod download
- run:
name: Run Go unit tests
command: go test -v ./...
build_and_test_php:
docker:
- image: cimg/php:8.1-browsers
- image: cimg/redis:6.2 # Service container for tests
working_directory: ~/repo/php-monolith
steps:
- checkout:
path: ~/repo
- run:
name: Install PHP dependencies
command: composer install -n --prefer-dist
- run:
name: Run PHPUnit tests
command: ./vendor/bin/phpunit
# ... Docker构建和推送的Job(此处省略) ...
deploy:
docker:
- image: cimg/base:stable
parameters:
environment:
type: string
default: "staging"
steps:
# 这里使用一个脚本来部署,实际项目中可能是kubectl apply, aws ecs update-service等
- run:
name: Deploy to << parameters.environment >>
command: |
echo "Deploying both services to << parameters.environment >>"
./scripts/deploy.sh << parameters.environment >>
workflows:
build_test_deploy:
jobs:
- build_and_test_go
- build_and_test_php
# 假设有docker_build_push_go和docker_build_push_php两个job
# - docker_build_push_go:
# requires:
# - build_and_test_go
# - docker_build_push_php:
# requires:
# - build_and_test_php
- deploy:
name: deploy_staging
environment: "staging"
# requires:
# - docker_build_push_go
# - docker_build_push_php
- hold_for_prod:
type: approval
requires:
- deploy_staging
- deploy:
name: deploy_production
environment: "production"
requires:
- hold_for_prod
这个配置文件定义了并行的测试任务和一个参数化的部署任务。通过approval
类型的job,我们可以在部署到生产环境前进行手动确认,这是生产发布的标准实践。
随着迁移的深入,我们将开始把写操作也迁移到Go服务。例如,移动卡片这个高频操作。前端会开始调用新的API端点 POST /api/v2/cards/{id}/move
。Go服务将直接处理这个请求。
// file: internal/handlers/card_handler.go (New Go Service)
package handlers
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"github.com/go-redis/redis/v8"
"github.com/gorilla/mux"
)
type CardMover struct {
DB *sql.DB
RDB *redis.Client
}
type MoveCardRequest struct {
NewColumnID int `json:"new_column_id"`
NewPosition int `json:"new_position"`
}
// MoveCard handles the request to move a card.
func (cm *CardMover) MoveCard(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
cardID := vars["id"]
var reqBody MoveCardRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// 在一个事务中完成数据库更新,这是保证数据一致性的关键
tx, err := cm.DB.Begin()
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback() // 如果后续操作失败,回滚事务
// ...
// 复杂的数据库操作:更新卡片的位置,可能还需要调整同列中其他卡片的位置
// _, err = tx.ExecContext(r.Context(), "UPDATE cards SET column_id = $1, position = $2 WHERE id = $3", reqBody.NewColumnID, reqBody.NewPosition, cardID)
// if err != nil { ... }
// ...
if err := tx.Commit(); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 事务成功后,发布事件到Redis
// 现在事件的源头是Go服务,而不是PHP
eventPayload := map[string]interface{}{
"event_type": "card_moved",
"timestamp": time.Now().Unix(),
"data": map[string]interface{}{
"card_id": cardID,
"new_column_id": reqBody.NewColumnID,
"new_position": reqBody.NewPosition,
},
}
payloadBytes, _ := json.Marshal(eventPayload)
if err := cm.RDB.Publish(r.Context(), "kanban_events", payloadBytes).Err(); err != nil {
// 这里的坑和PHP端一样:发布失败怎么办?
// 至少需要记录严重错误日志
log.Printf("CRITICAL: Failed to publish card move event for card %s", cardID)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
现在,移动卡片的整个逻辑,从API接收、数据库操作到事件发布,都由Go服务闭环完成。PHP单体完全不再参与这个过程。代理的路由规则确保了这一点。这就是绞杀者模式的威力:一次一个功能,用新的实现替换旧的实现,整个过程对用户是透明的。
当前方案引入了额外的运维复杂性:需要同时维护Go和PHP两个技术栈的运行时,并且增加了一个Redis作为消息总线。这个代理层本身也需要做到高可用,否则会成为整个系统的单点故障。数据同步的可靠性完全依赖于Redis Pub/Sub,对于金融等一致性要求极高的场景,可能需要引入更可靠的消息队列或分布式事务方案。
未来的迭代路径非常清晰:持续识别PHP单体中的性能瓶颈或业务变更频繁的模块,逐一在Go中实现并替换。当所有核心功能都被迁移到Go服务后,PHP单体就可以被彻底移除,代理层也可以根据情况简化或演变为正式的API网关。这个过程是漫长的,但每一步都为系统带来了切实的价值,并有效控制了重构风险。