博客 / 詳情

返回

B站服務器開發一二面

今天分享一下訓練營內部朋友在B站遊戲服務器開發面試的詳解,

主要整理了問到的技術問題,項目介紹類問題去掉了,覆蓋分佈式、中間件、數據庫、併發控制等知識點,大家可以參考學習一下。

一面

1. 項目最終一致性的設計思路

核心思路:基於“事務消息+重試機制+冪等性”實現,優先選擇低侵入性方案,適用於訂單支付後庫存、積分、日誌等跨服務同步場景。

具體實現(以訂單支付為例):

  1. 本地事務與消息發送原子性:使用“本地消息表+定時任務”或 RocketMQ 事務消息。比如用 RocketMQ 時,先執行本地訂單更新(狀態改為“待支付”→“已支付”),成功後提交事務消息,失敗則回滾本地事務。
  2. 消息消費與重試:下游服務(庫存、積分)訂閲事務消息,消費成功則更新自身狀態,失敗則觸發 MQ 重試(階梯式重試:10s/30s/5min,避免瞬時故障)。
  3. 冪等性保障:每個消息攜帶唯一 ID(如訂單號+流水號),下游服務消費前先查“消息消費記錄表”,已消費則直接返回成功,未消費則執行邏輯。
  4. 最終兜底:定時任務掃描“未同步成功”的訂單,主動觸發補償邏輯(如調用庫存服務接口重試),確保最終所有服務狀態一致。

2. 項目異步設計的思路

核心思路:解耦服務依賴、提高吞吐量,優先用“消息隊列+Go 協程”組合,覆蓋跨服務異步和本地異步場景。

具體設計:

  1. 跨服務異步(解耦):用 Kafka/RocketMQ 做異步通信,比如用户註冊後,同步發送“註冊成功”消息,下游服務(短信、郵件、日誌)訂閲消費,主流程無需等待。
  2. 本地異步(提效):用 Go 協程處理無依賴的本地任務,比如訂單創建後,啓動協程異步生成訂單快照、記錄操作日誌,通過 sync.WaitGroup 控制協程等待(如需等待結果)或 channel 傳遞結果。
  3. 關鍵保障:

    • 冪等性:同最終一致性的消息 ID 校驗;
    • 超時處理:用 context.WithTimeout 控制協程執行時間,避免阻塞;
    • 錯誤處理:協程 panic 捕獲(defer recover())、消息消費失敗入死信隊列,定期覆盤;
    • 結果回調:如需同步異步結果,用“回調函數+channel”或“狀態輪詢”(如前端輪詢訂單支付狀態)。

項目落地:遊戲充值接口通過異步化改造,吞吐量從 500 QPS 提升至 3000 QPS,響應時間從 300ms 降至 50ms。

3. 消息隊列怎麼消費不同標籤的信息

以 RocketMQ(Tag 機制)和 Kafka(Topic+Partition 二級分類)為例,核心是“ broker 端過濾+消費端訂閲”:

  1. 標籤(Tag)設計:Tag 是消息的二級分類,基於業務場景劃分(如訂單消息:ORDER_PAID/ORDER_CANCELLED/ORDER_REFUNDED)。
  2. 消費端訂閲邏輯(Go 實現):

    • RocketMQ:使用 Go SDK(如 github.com/apache/rocketmq-client-go),在創建消費者時,通過 ConsumerOption 指定訂閲的 Tag,格式為 Topic:Tag1||Tag2(多 Tag 用 || 分隔),Broker 會僅將匹配 Tag 的消息投遞給消費者。
    • Kafka:無原生 Tag,但可通過“Topic+消息頭”模擬,消費端讀取消息頭中的 tag 字段過濾,或直接按 Tag 拆分 Topic(如 order_paid_topic/order_cancelled_topic),更高效。
  3. 優勢:Broker 端過濾減少無效消息傳輸,提升消費效率;消費端可靈活訂閲所需 Tag,實現業務解耦。

4. Golang 的線程池、協程池的使用?比如 running buffer

Go 無內置線程池/協程池,但協程(Goroutine)輕量(初始棧 2KB),可通過 channel 手動實現協程池,核心是“控制併發數+任務調度”:

(1)協程池核心設計
  • 核心組件:任務隊列(taskChan)、worker 協程池、併發控制(maxWorkers)、運行狀態標識(running buffer,即當前活躍 worker 數)。
  • 實現步驟(Go 代碼簡化):

    type Task func() error
    
    type Pool struct {
        taskChan   chan Task       // 任務隊列
        maxWorkers int             // 最大併發數
        running    int32           //  當前運行的worker數(原子變量,避免競態)
        ctx        context.Context
        cancel     context.CancelFunc
    }
    
    // 初始化協程池
    func NewPool(maxWorkers int) *Pool {
        ctx, cancel := context.WithCancel(context.Background())
        pool := &Pool{
            taskChan:   make(chan Task, 100), // 任務隊列緩衝
            maxWorkers: maxWorkers,
            ctx:        ctx,
            cancel:     cancel,
        }
        // 啓動worker
        for i := 0; i < maxWorkers; i++ {
            go pool.worker()
        }
        return pool
    }
    
    // worker協程:循環消費任務
    func (p *Pool) worker() {
        defer atomic.AddInt32(&p.running, -1)
        atomic.AddInt32(&p.running, 1)
        for {
            select {
            case <-p.ctx.Done():
                return
            case task, ok := <-p.taskChan:
                if !ok {
                    return
                }
                _ = task() // 執行任務
            }
        }
    }
    
    // 提交任務
    func (p *Pool) Submit(task Task) error {
        select {
        case <-p.ctx.Done():
            return fmt.Errorf("pool closed")
        case p.taskChan <- task:
            return nil
        }
    }
(2)關鍵概念與使用場景
  • running buffer:用 atomic.Int32 維護當前運行的 worker 數,可用於監控協程池負載(如通過 Prometheus 暴露指標)。
  • 使用場景:高併發 I/O 操作(如批量調用第三方接口、數據庫批量寫入)、避免無限制創建協程導致的內存溢出。
  • 注意點:任務隊列需設置緩衝(避免提交任務阻塞)、worker 優雅退出(通過 context 控制)、錯誤處理(任務執行失敗需記錄日誌或重試)。

5. 用的什麼中間件監聽數據庫 binlog

項目中用 Canal 監聽 MySQL binlog,核心是“模擬 MySQL 從庫同步協議,解析 binlog 並推送變更”:

  1. 工作流程:

    • Canal 偽裝成 MySQL 從庫,向主庫發送 dump 命令,獲取 binlog 日誌;
    • 解析 binlog(支持 row 格式,記錄具體數據變更),提取表名、操作類型(insert/update/delete)、變更前後數據;
    • 通過 Canal Client(Go SDK:github.com/alibaba/canal-go)訂閲變更事件,推送至業務邏輯(如同步數據到 Redis、ES,或觸發跨服務通知)。
  2. 優勢:輕量、低侵入(無需修改業務代碼)、支持高可用部署(Canal Server 集羣)。

6. Redis 常用的數據結構

數據結構 核心用途 項目應用場景
String 簡單鍵值存儲、計數器 存儲玩家驗證碼(key=player:{id}:code)、遊戲在線人數計數(INCR/DECR)
Hash 複雜對象存儲(字段-值映射) 存儲玩家信息(key=player:{id},field=name/level/gold)、商品屬性
List 隊列、棧、消息列表 遊戲公告隊列(LPUSH/RPOP)、玩家郵件列表
Set 去重、交集/並集運算 玩家好友關係(SADD/SISMEMBER)、抽獎活動去重(避免重複中獎)
Sorted Set 有序排序、排行榜 遊戲戰力排行榜(ZADD/ZRANGE)、限時活動積分排名
Bitmap 位運算、布爾值存儲 玩家簽到記錄(key=sign:{date},bit=playerID,1=已簽到)
Geo 地理位置計算 遊戲附近玩家查找(GEORADIUS)

進階用法:Hash 用 HSCAN 避免大 key 阻塞、Sorted Set 用 ZREMRANGEBYRANK 維護TopN排行榜、String 用 SETEX 實現過期緩存。

7. ETCD 的作用

ETCD 是分佈式鍵值存儲(基於 Raft 協議),核心作用是“分佈式一致性保障”,項目中主要用於 3 個場景:

  1. 服務註冊與發現:微服務(如遊戲網關、戰鬥服、道具服)啓動時向 ETCD 註冊(key=/services/{serviceName}/{instanceID},value=服務地址+元數據),客户端通過 ETCD 的 Watch 機制監聽服務變更,動態獲取可用實例(配合 gRPC 負載均衡)。
  2. 配置中心:存儲全局配置(如數據庫連接池大小、活動開關、限流閾值),通過 Watch 機制實現配置動態更新(無需重啓服務),Go 中用 etcd/clientv3 訂閲配置變更。
  3. 分佈式鎖:基於 ETCD 的 Lease(租約)+ CAS 操作實現,用於跨服務併發控制(如遊戲跨服活動報名、分佈式任務調度),避免死鎖(租約過期自動釋放鎖)。

優勢:強一致性、高可用(集羣部署)、輕量、支持 TTL 過期鍵。

8. 百庫百表分庫分表思路(玩家場景)

核心思路:水平分片(按玩家 ID 哈希分片),目標是分散數據壓力、提升查詢效率,適配百萬級玩家數據存儲:

  1. 分片維度選擇:按玩家 ID 分片(玩家操作自身數據時,可直接路由到對應庫表,無跨庫聯查)。
  2. 分片策略:

    • 分庫分表規則:100 庫 × 100 表 = 10000 張表。玩家 ID 經過哈希計算(如 hash(playerID) % 100)得到庫索引,hash(playerID) / 100 % 100 得到表索引,最終路由到 db{庫索引}.t_player_{表索引}
    • 哈希算法:用一致性哈希(帶虛擬節點),支持後續擴容(新增庫表時僅遷移部分數據,影響範圍小)。
  3. 中間件選型:ShardingSphere-JDBC(Go 項目中用 shardingsphere-go),透明化分庫分表邏輯(業務代碼無需關注分片規則,直接操作邏輯表)。
  4. 關鍵問題解決:

    • 全局 ID:用雪花算法(Snowflake)生成唯一訂單號/玩家 ID,避免分庫分表後 ID 衝突。
    • 跨庫查詢:避免跨庫聯查,通過“寬表冗餘”(如玩家訂單表冗餘玩家基礎信息)或“應用層聚合”(先查各庫數據,再在服務端合併)。
    • 擴容方案:新增庫表時,基於一致性哈希遷移舊數據,雙寫新舊庫一段時間(確保數據一致),再切換到新庫表。

項目落地:遊戲玩家中心存儲 500 萬玩家數據,分 100 庫 100 表,單表數據量控制在 5000 以內,查詢響應時間穩定在 10ms 內。

二面

1. 壓測時遇到的性能瓶頸及解決

壓測工具:用 k6(Go 編寫,高併發支持)+ Prometheus+Grafana 監控指標(QPS、響應時間、CPU/內存/網絡),遇到的核心瓶頸及解決方案:

瓶頸類型 現象 排查方式 解決方案
數據庫慢查詢 接口響應時間>500ms,MySQL CPU 100% EXPLAIN 分析 SQL,慢查詢日誌 1. 給訂單表添加聯合索引(player_id+create_time);2. 分頁查詢優化(用遊標代替 limit offset);3. 讀寫分離(讀請求路由到從庫)
Redis 緩存穿透 大量請求穿透到數據庫,Redis 命中率<80% Redis 監控面板查看命中率 1. 無效 key 緩存空值(SETEX key 3600 "");2. 布隆過濾器(RedisBloom)過濾不存在的玩家 ID
協程泄露 內存持續增長,協程數>10w pprof 分析 goroutine 棧 1. 協程池控制併發數(maxWorkers=100);2. 用 context.WithTimeout 控制協程生命週期,避免無限阻塞
網絡瓶頸 跨服務調用延遲>200ms tcpdump 抓包,鏈路追蹤(Jaeger) 1. 服務本地緩存熱點數據(如活動配置);2. gRPC 連接池優化(複用連接,減少握手開銷)

優化結果:接口 QPS 從 800 提升至 5000,響應時間穩定在 50-80ms,CPU 使用率控制在 70% 以內。

2. MySQL 相關優化

從“索引、SQL、配置、架構”四層優化,結合項目實踐:

  1. 索引優化:

    • 核心原則:給查詢頻繁的字段建索引,避免過度索引(影響寫入性能);
    • 實踐:玩家訂單表(player_id、create_time、status)建聯合索引,覆蓋查詢(select id, amount from t_order where player_id=? and status=? order by create_time desc),避免回表。
  2. SQL 優化:

    • 避免 select *(只查需要的字段)、避免 or(用 union 代替)、子查詢轉 join;
    • 實踐:將“查詢玩家近 30 天訂單並關聯商品信息”的子查詢,改為 join 查詢,執行時間從 300ms 降至 50ms。
  3. 配置優化:

    • innodb_buffer_pool_size = 物理內存的 50%-70%(緩存數據和索引,減少磁盤 I/O);
    • max_connections = 2000(適配高併發場景);
    • 關閉 binlog 或設置為 row 格式(減少 binlog 體積,提高寫入性能)。
  4. 架構優化:

    • 主從複製(一主兩從),讀請求分流到從庫(通過 ShardingSphere-JDBC 實現讀寫分離);
    • 分庫分表(如玩家表、訂單表),分散單庫單表壓力。

3. 實際項目中發現 MySQL 查詢瓶頸的方法

核心是“監控+日誌+執行計劃”三位一體,步驟如下:

  1. 慢查詢日誌定位:開啓 MySQL 慢查詢日誌(slow_query_log=1long_query_time=1),捕獲執行時間>1s 的 SQL,定期分析日誌(用 pt-query-digest 工具彙總)。
  2. 執行計劃分析:對慢查詢用 EXPLAIN 分析,重點看 type(索引類型,如 ref、range 優於 all)、key(是否使用索引)、rows(掃描行數,越少越好)、Extra(是否 Using filesort/Using temporary,需優化)。
  3. 實時監控:通過 Prometheus+Grafana 監控 MySQL 指標(slow_queries 慢查詢數、innodb_rows_read 掃描行數、Threads_running 運行線程數),設置閾值告警(如慢查詢數>10 觸發告警)。
  4. 業務日誌關聯:在應用日誌中記錄 SQL 執行時間(如 Go 中用 sqlx 攔截器),當接口響應變慢時,直接定位到耗時 SQL。

項目案例:通過慢查詢日誌發現“玩家累計充值金額查詢”SQL 未走索引,掃描全表(rows=50w),用 EXPLAIN 分析後,給 player_id 建索引,查詢時間從 1.2s 降至 8ms。

4. 分佈式系統 100 台服務器,玩家報錯的處理流程

核心思路:快速定位故障範圍→精準排查根因→臨時止損→永久修復,步驟如下:

  1. 收集報錯信息:讓玩家提供“報錯提示(如‘支付失敗’)、操作時間、玩家 ID、服務器區服”,前端同時上報報錯時的 traceID(鏈路追蹤 ID)。
  2. 定位故障服務與節點:

    • 通過 traceID 在 Jaeger 中查詢跨服務調用鏈路,確認是哪個服務(如支付服、訂單服)報錯;
    • 在 ELK 日誌平台中,按“traceID+玩家 ID+時間範圍”過濾日誌,找到報錯的服務器節點(IP+端口)。
  3. 排查節點問題:

    • 應用日誌:查看該節點的錯誤堆棧,定位代碼層面問題(如空指針、數據庫連接超時);
    • 系統監控:查看節點的 CPU、內存、磁盤 I/O、網絡(用 Prometheus+Grafana),是否存在資源耗盡;
    • 依賴服務:檢查該節點依賴的數據庫、Redis、MQ 是否正常(如 Redis 連接超時、數據庫主從切換)。
  4. 臨時止損:

    • 若單節點故障:通過負載均衡下線該節點,將流量轉發到其他健康節點;
    • 若服務級故障:觸發熔斷(如用 Hystrix/Resilience4j),返回友好提示(“系統臨時維護,請稍後再試”),避免雪崩。
  5. 永久修復與覆盤:

    • 修復代碼 bug(如空指針判斷、重試機制優化);
    • 優化監控告警(補充關鍵鏈路告警);
    • 覆盤會議,總結故障原因(如“未處理 Redis 連接超時”),避免同類問題。

5. 如何定位日誌

基於“分佈式日誌架構+鏈路追蹤”,實現日誌快速定位,架構:ELK(Elasticsearch+Logstash+Kibana)+ 鏈路追蹤(Jaeger):

  1. 日誌規範:

    • 統一日誌格式(JSON 格式),包含核心字段:traceID(鏈路追蹤 ID)、spanIDserviceName(服務名)、instanceIP(節點 IP)、playerID(玩家 ID)、time(時間戳)、level(日誌級別)、msg(日誌內容)、stack(錯誤堆棧)。
    • 鏈路追蹤透傳:用 gRPC 攔截器或 HTTP 中間件,在服務間傳遞 traceID,確保同一請求的所有日誌都攜帶相同 traceID
  2. 定位步驟:

    • 玩家報錯後,獲取 traceID(從前端或玩家提供的報錯信息中提取);
    • 打開 Kibana,在索引中按 traceID:xxx 過濾,獲取該請求的所有日誌(從網關→業務服→依賴服務);
    • 按時間排序日誌,找到報錯節點的錯誤堆棧,定位問題(如“支付服調用微信支付接口超時”);
    • 結合 Jaeger 查看該 traceID 的調用鏈路,確認超時環節(如微信支付接口響應時間>3s)。

項目落地:通過該方案,將日誌定位時間從 30 分鐘縮短至 5 分鐘,大幅提升故障排查效率。

6. 超買超賣的訂單處理

核心是“併發控制+原子操作”,基於 Redis+MySQL 實現雙重保障:

  1. 方案一:Redis 分佈式鎖+庫存預扣減(高併發場景首選)

    • 鎖 key:lock:goods:{goodsID}(同一商品共享一把鎖);
    • 流程:

      1. 玩家下單時,用 Redis SET NX EX 命令獲取鎖(SET lock:goods:123 1 EX 10 NX);
      2. 獲取鎖成功後,查詢 Redis 庫存(GET goods:stock:123),庫存不足則返回“商品已售罄”;
      3. 庫存充足則預扣減(DECR goods:stock:123),釋放鎖(DEL lock:goods:123);
      4. 預扣減成功後,異步寫入數據庫(訂單表+庫存表),數據庫庫存表加行鎖(select stock from t_goods where id=? for update),確保最終庫存一致。
    • 注意:鎖超時時間需大於業務執行時間,避免死鎖;用 Lua 腳本保證“查庫存+扣庫存”原子性。
  2. 方案二:MySQL 樂觀鎖(低併發場景,無鎖競爭)

    • 庫存表添加 version 字段;
    • 扣庫存 SQL:update t_goods set stock=stock-1, version=version+1 where id=? and stock>=1 and version=?
    • 執行後判斷影響行數,若為 0 則説明庫存不足或已被其他請求扣減,返回“操作失敗”。

項目落地:遊戲限時搶購活動用方案一,支持 1w+ QPS 併發下單,超買超賣率為 0,庫存一致性 100%。

7. 併發場景避免二次執行(如重複發貨)

核心是“冪等性設計”,結合業務場景選擇以下方案:

  1. 方案一:唯一請求 ID(客户端層面)

    • 客户端(如遊戲客户端)生成唯一請求 ID(UUID),每次請求攜帶該 ID;
    • 服務端接收請求後,先查 Redis:EXISTS request:id:{requestID},存在則返回“操作已執行”,不存在則執行業務邏輯;
    • 業務邏輯執行成功後,將請求 ID 存入 Redis(SET request:id:{requestID} 1 EX 3600),過期時間設為業務操作有效時間。
  2. 方案二:業務唯一鍵(數據庫層面)

    • 訂單表創建唯一索引:UNIQUE KEY uk_player_goods (player_id, goods_id, activity_id)(同一玩家同一活動同一商品只能創建一次訂單);
    • 重複請求時,數據庫會拋出 Duplicate key error,服務端捕獲後返回“操作已執行”。
  3. 方案三:分佈式鎖(服務端層面)

    • 鎖 key:lock:player:{playerID}:goods:{goodsID}(同一玩家同一商品的操作共享一把鎖);
    • 只有獲取鎖的請求能執行業務邏輯,其他請求等待或直接返回,避免併發執行。

項目落地:遊戲道具發放用“方案一+方案二”,既通過請求 ID 快速攔截重複請求,又通過數據庫唯一索引兜底,確保無二次發貨。

8. 支付體系的回調

支付回調是支付平台(微信/支付寶)向商户服務器發送的異步支付結果通知,核心流程:“簽名驗證+冪等處理+訂單更新+響應確認”:

  1. 完整流程:

    • 回調配置:在支付平台(如微信支付商户平台)配置回調地址(必須 HTTPS,公網可訪問);
    • 回調觸發:用户支付成功後,支付平台向回調地址發送 POST 請求,參數包含:支付流水號、訂單號、支付金額、簽名等;
    • 簽名驗證:服務端用商户密鑰驗證參數簽名(如微信支付的 HMAC-SHA256 簽名),確保請求來自官方,防止偽造;
    • 冪等處理:通過訂單號查詢本地訂單狀態,若已處理(如“已支付”),直接返回成功響應;
    • 業務處理:未處理則更新訂單狀態為“已支付”,執行後續邏輯(扣庫存、發道具、加積分);
    • 響應確認:向支付平台返回指定格式的成功響應(如微信支付返回 <xml><return_code><![CDATA[SUCCESS]]></return_code></xml>),否則支付平台會階梯式重試(如 15s/30s/1min/2min/5min/10min/30min/1h/2h/6h/15h,共 11 次)。
  2. 關鍵注意點:

    • 簽名驗證:必須驗證,避免惡意回調;
    • 冪等處理:支付平台會重試,必須保證回調處理冪等;
    • 日誌記錄:詳細記錄回調參數、處理結果,便於排查問題;
    • 超時處理:回調處理時間需<10s,避免支付平台判定超時重試。

9. 僅用 Redis 和 MySQL 防止多個請求反覆執行

核心是“Redis 原子操作快速攔截+MySQL 唯一索引兜底”,無需額外中間件:

  1. 實現方案(以玩家購買商品為例):

    • 步驟 1:Redis 原子判斷+鎖定(快速攔截)

      • 玩家發起購買請求時,服務端執行 Redis 命令:SETNX lock:player:{playerID}:goods:{goodsID} 1 EX 30(30s 過期,避免死鎖);
      • 若返回 1(獲取鎖成功),則繼續執行;若返回 0(已被其他請求鎖定),則返回“操作中,請稍後再試”。
    • 步驟 2:MySQL 唯一索引兜底(防止 Redis 宕機)

      • 訂單表創建唯一索引:uk_player_goods (player_id, goods_id),確保同一玩家同一商品只能創建一次訂單;
      • 執行訂單插入 SQL:insert into t_order (player_id, goods_id, amount, status) values (?, ?, ?, ?)
      • 若插入成功(影響行數=1),則執行後續邏輯(扣庫存、發道具);若拋出 Duplicate key error,則返回“操作已執行”。
    • 步驟 3:釋放鎖

      • 訂單創建成功或失敗後,執行 DEL lock:player:{playerID}:goods:{goodsID} 釋放鎖(或等待自動過期)。
  2. 優勢:

    • Redis 層面快速攔截高併發重複請求,減少數據庫壓力;
    • MySQL 唯一索引兜底,即使 Redis 宕機,也能防止重複執行;
    • 無額外中間件依賴,部署簡單。

項目落地:遊戲內玩家購買月卡場景用該方案,支持 5k+ QPS 併發請求,無重複購買、重複發貨問題。

歡迎關注 ❤

我們搞了很多免費的面試真題共享羣,互通有無,一起刷題進步。

沒準能讓你能刷到自己意向公司的最新面試題呢。

感興趣的朋友們可以加我微信:wangzhongyang1993,備註:面試羣。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.