天天看點

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

Redis 高負載下的中斷優化

陳炳宇

背景

2015年年初以來,随着Redis産品的使用者量越來越大,接入服務越來越多,再加上京東Memcache和Redis兩套緩存融合,Redis服務端的總體請求量從年初最開始日通路量百億次級别上漲到高峰時段的萬億次級别,給運維和架構團隊都帶來了極大的挑戰。

原本穩定的環境也因為請求量的上漲帶來了很多不穩定的因素,其中一直困擾我們的就是網卡丢包問題。起初線上存在部分Redis節點還在使用千兆網卡的老舊伺服器,而緩存服務往往需要承載極高的查詢量,并要求毫秒級的響應速度,如此一來千兆網卡很快就出現了瓶頸。經過整治,我們将千兆網卡伺服器替換為了萬兆網卡伺服器,本以為可以高枕無憂,但是沒想到,在業務高峰時段,機器也竟然出現了丢包問題,而此時網卡帶寬使用還遠遠沒有達到瓶頸。

定位網絡丢包的原因

從異常名額入手

首先,我們在系統監控的net.if.in.dropped名額中,看到有大量資料丢包異常,那麼第一步就是要了解這個名額代表什麼。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

這個名額的資料源,是讀取/proc/net/dev中的資料,監控Agent做簡單的處理之後上報。以下為/proc/net/dev 的一個示例,可以看到第一行Receive代表in,Transmit代表out,第二行即各個表頭字段,再往後每一行代表一個網卡裝置具體的值。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

其中各個字段意義如下:

字段 解釋
bytes The total number of bytes of data transmitted or received by the interface.
packets The total number of packets of data transmitted or received by the interface.
errs The total number of transmit or receive errors detected by the device driver.
drop The total number of packets dropped by the device driver.
fifo The number of FIFO buffer errors.
frame The number of packet framing errors.
colls The number of collisions detected on the interface.
compressed The number of compressed packets transmitted or received by the device driver. (This appears to be unused in the 2.2.15 kernel.)
carrier The number of carrier losses detected by the device driver.
multicast The number of multicast frames transmitted or received by the device driver.

通過上述字段解釋,我們可以了解丢包發生在網卡裝置驅動層面;但是想要了解真正的原因,需要繼續深入源碼。

/proc/net/dev的資料來源,根據源碼檔案net/core/net-procfs.c,可以知道上述名額是通過其中的dev_seq_show()函數和dev_seq_printf_stats()函數輸出的:

static int dev_seq_show(struct seq_file *seq, void *v)

{

    if (v == SEQ_START_TOKEN)

        seq_puts(seq, "Inter-|   Receive                            "

                  "                    |  Transmit\n"

                  " face |bytes    packets errs drop fifo frame "

                  "compressed multicast|bytes    packets errs "

                  "drop fifo colls carrier compressed\n");

    else

        dev_seq_printf_stats(seq, v);

    return 0;

}

static void dev_seq_printf_stats(struct seq_file *seq, struct net_device *dev)

{

    struct rtnl_link_stats64 temp;

    const struct rtnl_link_stats64 *stats = dev_get_stats(dev, &temp);

    seq_printf(seq, "%6s: %7llu %7llu %4llu %4llu %4llu %5llu %10llu %9llu "

           "%8llu %7llu %4llu %4llu %4llu %5llu %7llu %10llu\n",

           dev->name, stats->rx_bytes, stats->rx_packets,

           stats->rx_errors,

           stats->rx_dropped + stats->rx_missed_errors,

           stats->rx_fifo_errors,

           stats->rx_length_errors + stats->rx_over_errors +

            stats->rx_crc_errors + stats->rx_frame_errors,

           stats->rx_compressed, stats->multicast,

           stats->tx_bytes, stats->tx_packets,

           stats->tx_errors, stats->tx_dropped,

           stats->tx_fifo_errors, stats->collisions,

           stats->tx_carrier_errors +

            stats->tx_aborted_errors +

            stats->tx_window_errors +

            stats->tx_heartbeat_errors,

           stats->tx_compressed);

}

dev_seq_printf_stats()函數裡,對應drop輸出的部分,能看到由兩塊組成:stats->rx_dropped+stats->rx_missed_errors。

繼續查找dev_get_stats函數可知,rx_dropped和rx_missed_errors 都是從裝置擷取的,并且需要裝置驅動實作。

struct rtnl_link_stats64 *dev_get_stats(struct net_device *dev,

                    struct rtnl_link_stats64 *storage)

{

    const struct net_device_ops *ops = dev->netdev_ops;

    if (ops->ndo_get_stats64) {

        memset(storage, 0, sizeof(*storage));

        ops->ndo_get_stats64(dev, storage);

    } else if (ops->ndo_get_stats) {

        netdev_stats_to_stats64(storage, ops->ndo_get_stats(dev));

    } else {

        netdev_stats_to_stats64(storage, &dev->stats);

    }  

    storage->rx_dropped += (unsigned long)atomic_long_read(&dev->rx_dropped);

    storage->tx_dropped += (unsigned long)atomic_long_read(&dev->tx_dropped);

    storage->rx_nohandler += (unsigned long)atomic_long_read(&dev->rx_nohandler);

    return storage;

}

結構體 rtnl_link_stats64 的定義在 /usr/include/linux/if_link.h 中:

struct rtnl_link_stats64 {

    __u64   rx_packets;    

    __u64   tx_packets;    

    __u64   rx_bytes;      

    __u64   tx_bytes;      

    __u64   rx_errors;     

    __u64   tx_errors;     

    __u64   rx_dropped;    

    __u64   tx_dropped;    

    __u64   multicast;     

    __u64   collisions;

    __u64   rx_length_errors;

    __u64   rx_over_errors;    

    __u64   rx_crc_errors;     

    __u64   rx_frame_errors;   

    __u64   rx_fifo_errors;    

    __u64   rx_missed_errors;  

    __u64   tx_aborted_errors;

    __u64   tx_carrier_errors;

    __u64   tx_fifo_errors;

    __u64   tx_heartbeat_errors;

    __u64   tx_window_errors;

    __u64   rx_compressed;

    __u64   tx_compressed;

};

至此,我們知道rx_dropped是Linux中的緩沖區空間不足導緻的丢包,而rx_missed_errors則在注釋中寫的比較籠統。有資料指出,rx_missed_errors是fifo隊列(即rx ring buffer)滿而丢棄的數量,但這樣的話也就和rx_fifo_errors等同了。後來公司内網絡核心研發大牛給了我們點撥:不同網卡自己實作不一樣,比如Intel的igb網卡rx_fifo_errors在missed的基礎上,還加上了RQDPC計數,而ixgbe就沒這個統計。RQDPC計數是描述符不夠的計數,missed是fifo滿的計數。是以對于ixgbe來說,rx_fifo_errors和rx_missed_errors确實是等同的。

通過指令ethtool -S eth0可以檢視網卡一些統計資訊,其中就包含了上文提到的幾個重要名額rx_dropped、rx_missed_errors、rx_fifo_errors等。但實際測試後,我發現不同網卡型号給出的名額略有不同,比如Intel ixgbe就能取到,而Broadcom bnx2/tg3則隻能取到rx_discards(對應rx_fifo_errors)、rx_fw_discards(對應rx_dropped)。這表明,各家網卡廠商裝置内部對這些丢包的計數器、名額的定義略有不同,但通過驅動向核心提供的統計資料都封裝成了struct rtnl_link_stats64定義的格式。

在對丢包伺服器進行檢查後,發現rx_missed_errors為0,丢包全部來自rx_dropped。說明丢包發生在Linux核心的緩沖區中。接下來,我們要繼續探索到底是什麼緩沖區引起了丢包問題,這就需要完整地了解伺服器接收資料包的過程。

了解接收資料包的流程

接收資料包是一個複雜的過程,涉及很多底層的技術細節,但大緻需要以下幾個步驟:

  1. 網卡收到資料包。
  2. 将資料包從網卡硬體緩存轉移到伺服器記憶體中。
  3. 通知核心處理。
  4. 經過TCP/IP協定逐層處理。
  5. 應用程式通過read()從socket buffer讀取資料。
Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

将網卡收到的資料包轉移到主機記憶體(NIC與驅動互動)

NIC在接收到資料包之後,首先需要将資料同步到核心中,這中間的橋梁是rx ring buffer。它是由NIC和驅動程式共享的一片區域,事實上,rx ring buffer存儲的并不是實際的packet資料,而是一個描述符,這個描述符指向了它真正的存儲位址,具體流程如下:

  1. 驅動在記憶體中配置設定一片緩沖區用來接收資料包,叫做sk_buffer;
  2. 将上述緩沖區的位址和大小(即接收描述符),加入到rx ring buffer。描述符中的緩沖區位址是DMA使用的實體位址;
  3. 驅動通知網卡有一個新的描述符;
  4. 網卡從rx ring buffer中取出描述符,進而獲知緩沖區的位址和大小;
  5. 網卡收到新的資料包;
  6. 網卡将新資料包通過DMA直接寫到sk_buffer中。
Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

當驅動處理速度跟不上網卡收包速度時,驅動來不及配置設定緩沖區,NIC接收到的資料包無法及時寫到sk_buffer,就會産生堆積,當NIC内部緩沖區寫滿後,就會丢棄部分資料,引起丢包。這部分丢包為rx_fifo_errors,在 /proc/net/dev中展現為fifo字段增長,在ifconfig中展現為overruns名額增長。

通知系統核心處理(驅動與Linux核心互動)

這個時候,資料包已經被轉移到了sk_buffer中。前文提到,這是驅動程式在記憶體中配置設定的一片緩沖區,并且是通過DMA寫入的,這種方式不依賴CPU直接将資料寫到了記憶體中,意味着對核心來說,其實并不知道已經有新資料到了記憶體中。那麼如何讓核心知道有新資料進來了呢?答案就是中斷,通過中斷告訴核心有新資料進來了,并需要進行後續處理。

提到中斷,就涉及到硬中斷和軟中斷,首先需要簡單了解一下它們的差別:

  • 硬中斷: 由硬體自己生成,具有随機性,硬中斷被CPU接收後,觸發執行中斷處理程式。中斷處理程式隻會處理關鍵性的、短時間内可以處理完的工作,剩餘耗時較長工作,會放到中斷之後,由軟中斷來完成。硬中斷也被稱為上半部分。
  • 軟中斷: 由硬中斷對應的中斷處理程式生成,往往是預先在代碼裡實作好的,不具有随機性。(除此之外,也有應用程式觸發的軟中斷,與本文讨論的網卡收包無關。)也被稱為下半部分。

當NIC把資料包通過DMA複制到核心緩沖區sk_buffer後,NIC立即發起一個硬體中斷。CPU接收後,首先進入上半部分,網卡中斷對應的中斷處理程式是網卡驅動程式的一部分,之後由它發起軟中斷,進入下半部分,開始消費sk_buffer中的資料,交給核心協定棧處理。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

通過中斷,能夠快速及時地響應網卡資料請求,但如果資料量大,那麼會産生大量中斷請求,CPU大部分時間都忙于進行中斷,效率很低。為了解決這個問題,現在的核心及驅動都采用一種叫NAPI(new API)的方式進行資料處理,其原理可以簡單了解為 中斷+輪詢,在資料量大時,一次中斷後通過輪詢接收一定數量包再傳回,避免産生多次中斷。

整個中斷過程的源碼部分比較複雜,并且不同驅動的廠商及版本也會存在一定的差別。 以下調用關系基于Linux-3.10.108及核心自帶驅動drivers/net/ethernet/intel/ixgbe:

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

注意到,enqueue_to_backlog函數中,會對CPU的softnet_data 執行個體中的接收隊列(input_pkt_queue)進行判斷,如果隊列中的資料長度超過netdev_max_backlog ,那麼資料包将直接丢棄,這就産生了丢包。netdev_max_backlog是由系統參數net.core.netdev_max_backlog指定的,預設大小是 1000。

static int enqueue_to_backlog(struct sk_buff *skb, int cpu,

                  unsigned int *qtail)

{

    struct softnet_data *sd;

    unsigned long flags;

    sd = &per_cpu(softnet_data, cpu);

    local_irq_save(flags);

    rps_lock(sd);

    if (skb_queue_len(&sd->input_pkt_queue) <= netdev_max_backlog) {

        if (skb_queue_len(&sd->input_pkt_queue)) {

enqueue:

            __skb_queue_tail(&sd->input_pkt_queue, skb);

            input_queue_tail_incr_save(sd, qtail);

            rps_unlock(sd);

            local_irq_restore(flags);

            return NET_RX_SUCCESS;

        }  

        if (!__test_and_set_bit(NAPI_STATE_SCHED, &sd->backlog.state)) {

            if (!rps_ipi_queued(sd))

                ____napi_schedule(sd, &sd->backlog);

        }  

        goto enqueue;

    }

    sd->dropped++;

    rps_unlock(sd);

    local_irq_restore(flags);

    atomic_long_inc(&skb->dev->rx_dropped);

    kfree_skb(skb);

    return NET_RX_DROP;

}

核心會為每個CPU Core都執行個體化一個softnet_data對象,這個對象中的input_pkt_queue用于管理接收的資料包。假如所有的中斷都由一個CPU Core來處理的話,那麼所有資料包隻能經由這個CPU的input_pkt_queue,如果接收的資料包數量非常大,超過中斷處理速度,那麼input_pkt_queue中的資料包就會堆積,直至超過netdev_max_backlog,引起丢包。這部分丢包可以在cat /proc/net/softnet_stat的輸出結果中進行确認:

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

其中每行代表一個CPU,第一列是中斷處理程式接收的幀數,第二列是由于超過 netdev_max_backlog 而丢棄的幀數。 第三列則是在 net_rx_action 函數中處理資料包超過 netdev_budget 指定數量或運作時間超過2個時間片的次數。在檢查線上伺服器之後,發現第一行CPU。硬中斷的中斷号及統計資料可以在/proc/interrupts中看到,對于多隊列網卡,當系統啟動并加載NIC裝置驅動程式子產品時,每個RXTX隊列會被初始化配置設定一個唯一的中斷向量号,它通知中斷處理程式該中斷來自哪個NIC隊列。在預設情況下,所有隊列的硬中斷都由CPU 0處理,是以對應的軟中斷邏輯也會在CPU 0上處理,在伺服器 TOP 的輸出中,也可以觀察到 %si 軟中斷部分,CPU 0的占比比其他core高出一截。

到這裡其實有存在一個疑惑,我們線上伺服器的核心版本及網卡都支援NAPI,而NAPI的處理邏輯是不會走到enqueue_to_backlog 中的,enqueue_to_backlog主要是非NAPI的處理流程中使用的。對此,我們覺得可能和目前使用的Docker架構有關,事實上,我們通過net.if.dropped 名額擷取到的丢包,都發生在Docker虛拟網卡上,而非主控端實體網卡上,是以很可能是Docker虛拟網橋轉發資料包之後,虛拟網卡層面産生的丢包,這裡由于涉及虛拟化部分,就不進一步分析了。

驅動及核心處理過程中的幾個重要函數:

(1)注冊中斷号及中斷處理程式,根據網卡是否支援MSI/MSIX,結果為:MSIX → ixgbe_msix_clean_rings,MSI → ixgbe_intr,都不支援 → ixgbe_intr。

static int ixgbe_request_irq(struct ixgbe_adapter *adapter)

{

    struct net_device *netdev = adapter->netdev;

    int err;

    if (adapter->flags & IXGBE_FLAG_MSIX_ENABLED)

        err = ixgbe_request_msix_irqs(adapter);

    else if (adapter->flags & IXGBE_FLAG_MSI_ENABLED)

        err = request_irq(adapter->pdev->irq, &ixgbe_intr, 0,

                  netdev->name, adapter);

    else

        err = request_irq(adapter->pdev->irq, &ixgbe_intr, IRQF_SHARED,

                  netdev->name, adapter);

    if (err)

        e_err(probe, "request_irq failed, Error %d\n", err);

    return err;

}

static int (struct ixgbe_adapter *adapter)

{

    …

    for (vector = 0; vector < adapter->num_q_vectors; vector++) {

        struct ixgbe_q_vector *q_vector = adapter->q_vector[vector];

        struct msix_entry *entry = &adapter->msix_entries[vector];

        err = request_irq(entry->vector, &ixgbe_msix_clean_rings, 0,

                  q_vector->name, q_vector);

        if (err) {

            e_err(probe, "request_irq failed for MSIX interrupt '%s' "

                  "Error: %d\n", q_vector->name, err);

            goto free_queue_irqs;

        }

    …

    }

}

(2)線上的多隊列網卡均支援MSIX,中斷處理程式入口為ixgbe_msix_clean_rings,裡面調用了函數napi_schedule(&q_vector->napi)。

static irqreturn_t ixgbe_msix_clean_rings(int irq, void *data)

{

    struct ixgbe_q_vector *q_vector = data;

    if (q_vector->rx.ring || q_vector->tx.ring)

        napi_schedule(&q_vector->napi);

    return IRQ_HANDLED;

}

(3)之後經過一些列調用,直到發起名為NET_RX_SOFTIRQ的軟中斷。到這裡完成了硬中斷部分,進入軟中斷部分,同時也上升到了核心層面。

static inline void napi_schedule(struct napi_struct *n)

{

    if (napi_schedule_prep(n))

        __napi_schedule(n);

}

void __napi_schedule(struct napi_struct *n)

{

    unsigned long flags;

    local_irq_save(flags);

    ____napi_schedule(this_cpu_ptr(&softnet_data), n);

    local_irq_restore(flags);

}

static inline void ____napi_schedule(struct softnet_data *sd,

                     struct napi_struct *napi)

{

    list_add_tail(&napi->poll_list, &sd->poll_list);

    __raise_softirq_irqoff(NET_RX_SOFTIRQ);

}

(4)NET_RX_SOFTIRQ對應的軟中斷處理程式接口是net_rx_action()。

static int __init net_dev_init(void)

{

    …

    open_softirq(NET_TX_SOFTIRQ, net_tx_action);

    open_softirq(NET_RX_SOFTIRQ, net_rx_action);

    …

}

(5)net_rx_action功能就是輪詢調用poll方法,這裡就是ixgbe_poll。一次輪詢的資料包數量不能超過核心參數net.core.netdev_budget指定的數量(預設值300),并且輪詢時間不能超過2個時間片。這個機制保證了單次軟中斷處理不會耗時太久影響被中斷的程式。

static void net_rx_action(struct softirq_action *h)

{

    struct softnet_data *sd = &__get_cpu_var(softnet_data);

    unsigned long time_limit = jiffies + 2;

    int budget = netdev_budget;

    void *have;

    local_irq_disable();

    while (!list_empty(&sd->poll_list)) {

        struct napi_struct *n;

        int work, weight;

        if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))

            goto softnet_break;

        local_irq_enable();

        n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);

        have = netpoll_poll_lock(n);

        weight = n->weight;

        work = 0;

        if (test_bit(NAPI_STATE_SCHED, &n->state)) {

            work = n->poll(n, weight);

            trace_napi_poll(n);

        }

        ……

    }

}

(6)ixgbe_poll之後的一系列調用就不一一詳述了,有興趣的同學可以自行研究,軟中斷部分有幾個地方會有類似if (static_key_false(&rps_needed))這樣的判斷,會進入前文所述有丢包風險的enqueue_to_backlog函數。 這裡的邏輯為判斷是否啟用了RPS機制,RPS是早期單隊列網卡上将軟中斷負載均衡到多個CPU Core的技術,它對資料流進行hash并配置設定到對應的CPU Core上,發揮多核的性能。不過現在基本都是多隊列網卡,不會開啟這個機制,是以走不到這裡,static_key_false是針對預設為false的static key 的優化判斷方式。這段調用的最後,deliver_skb會将接收的資料傳入一個IP層的資料結構中,至此完成二層的全部處理。

int netif_receive_skb(struct sk_buff *skb)

{

    int ret;

    net_timestamp_check(netdev_tstamp_prequeue, skb);

    if (skb_defer_rx_timestamp(skb))

        return NET_RX_SUCCESS;

    rcu_read_lock();

#ifdef CONFIG_RPS

    if (static_key_false(&rps_needed)) {

        struct rps_dev_flow voidflow, *rflow = &voidflow;

        int cpu = get_rps_cpu(skb->dev, skb, &rflow);

        if (cpu >= 0) {

            ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);

            rcu_read_unlock();

            return ret;

        }

    }

#endif

    ret = __netif_receive_skb(skb);

    rcu_read_unlock();

    return ret;

}

TCP/IP協定棧逐層處理,最終交給使用者空間讀取

資料包進到IP層之後,經過IP層、TCP層處理(校驗、解析上層協定,發送給上層協定),放入socket buffer,在應用程式執行read() 系統調用時,就能從socket buffer中将新資料從核心區拷貝到使用者區,完成讀取。

這裡的socket buffer大小即TCP接收視窗,TCP由于具備流量控制功能,能動态調整接收視窗大小,是以資料傳輸階段不會出現由于socket buffer接收隊列空間不足而丢包的情況(但UDP及TCP握手階段仍會有)。涉及TCP/IP協定的部分不是此次丢包問題的研究重點,是以這裡不再贅述。

網卡隊列

檢視網卡型号

  # lspci -vvv | grep Eth

01:00.0 Ethernet controller: Intel Corporation Ethernet Controller 10-Gigabit X540-AT2 (rev 03)

        Subsystem: Dell Ethernet 10G 4P X540/I350 rNDC

01:00.1 Ethernet controller: Intel Corporation Ethernet Controller 10-Gigabit X540-AT2 (rev 03)

        Subsystem: Dell Ethernet 10G 4P X540/I350 rNDC

# lspci -vvv

07:00.0 Ethernet controller: Intel Corporation I350 Gigabit Network Connection (rev 01)

        Subsystem: Dell Gigabit 4P X540/I350 rNDC

        Control: I/O- Mem+ BusMaster+ SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR- FastB2B- DisINTx+

        Status: Cap+ 66MHz- UDF- FastB2B- ParErr- DEVSEL=fast >TAbort- <TAbort- <MAbort- >SERR- <PERR- INTx-

        Latency: 0, Cache Line Size: 128 bytes

        Interrupt: pin D routed to IRQ 19

        Region 0: Memory at 92380000 (32-bit, non-prefetchable) [size=512K]

        Region 3: Memory at 92404000 (32-bit, non-prefetchable) [size=16K]

        Expansion ROM at 92a00000 [disabled] [size=512K]

        Capabilities: [40] Power Management version 3

                Flags: PMEClk- DSI+ D1- D2- AuxCurrent=0mA PME(D0+,D1-,D2-,D3hot+,D3cold+)

                Status: D0 NoSoftRst+ PME-Enable- DSel=0 DScale=1 PME-

        Capabilities: [50] MSI: Enable- Count=1/1 Maskable+ 64bit+

                Address: 0000000000000000  Data: 0000

                Masking: 00000000  Pending: 00000000

        Capabilities: [70] MSI-X: Enable+ Count=10 Masked-

                Vector table: BAR=3 offset=00000000

                PBA: BAR=3 offset=00002000

可以看出,網卡的中斷機制是MSI-X,即網卡的每個隊列都可以配置設定中斷(MSI-X支援2048個中斷)。

網卡隊列

 ...

 #define IXGBE_MAX_MSIX_VECTORS_82599    0x40

...

  u16 ixgbe_get_pcie_msix_count_generic(struct ixgbe_hw *hw)

 {

     u16 msix_count;

     u16 max_msix_count;

     u16 pcie_offset;

     switch (hw->mac.type) {

     case ixgbe_mac_82598EB:

         pcie_offset = IXGBE_PCIE_MSIX_82598_CAPS;

         max_msix_count = IXGBE_MAX_MSIX_VECTORS_82598;

         break;

     case ixgbe_mac_82599EB:

     case ixgbe_mac_X540:

     case ixgbe_mac_X550:

     case ixgbe_mac_X550EM_x:

     case ixgbe_mac_x550em_a:

         pcie_offset = IXGBE_PCIE_MSIX_82599_CAPS;

         max_msix_count = IXGBE_MAX_MSIX_VECTORS_82599;

         break;

     default:

         return 1;

     }

 ...

根據網卡型号确定驅動中定義的網卡隊列,可以看到X540網卡驅動中定義最大支援的IRQ Vector為0x40(數值:64)。

 static int ixgbe_acquire_msix_vectors(struct ixgbe_adapter *adapter)

 {

     struct ixgbe_hw *hw = &adapter->hw;

     int i, vectors, vector_threshold;

     vectors = max(adapter->num_rx_queues, adapter->num_tx_queues);

     vectors = max(vectors, adapter->num_xdp_queues);

     vectors = min_t(int, vectors, num_online_cpus());

通過加載網卡驅動,擷取網卡型号和網卡硬體的隊列數;但是在初始化misx vector的時候,還會結合系統線上CPU的數量,通過Sum = Min(網卡隊列,CPU Core) 來激活相應的網卡隊列數量,并申請Sum個中斷号。

如果CPU數量小于64,會生成CPU數量的隊列,也就是每個CPU會産生一個external IRQ。

我們線上的CPU一般是48個邏輯core,就會生成48個中斷号,由于我們是兩塊網卡做了bond,也就會生成96個中斷号。

驗證與複現網絡丢包

通過霸爺的一篇文章,我們在測試環境做了測試,發現測試環境的中斷确實有集中在CPU 0的情況,下面使用systemtap診斷測試環境軟中斷分布的方法:

global hard, soft, wq

probe irq_handler.entry {

hard[irq, dev_name]++;

}

probe timer.s(1) {

println("==irq number:dev_name")

foreach( [irq, dev_name] in hard- limit 5) {

printf("%d,%s->%d\n", irq, kernel_string(dev_name), hard[irq, dev_name]);     

}

println("==softirq cpu:h:vec:action")

foreach( [c,h,vec,action] in soft- limit 5) {

printf("%d:%x:%x:%s->%d\n", c, h, vec, symdata(action), soft[c,h,vec,action]);     

}

println("==workqueue wq_thread:work_func")

foreach( [wq_thread,work_func] in wq- limit 5) {

printf("%x:%x->%d\n", wq_thread, work_func, wq[wq_thread, work_func]);

}

println("\n")

delete hard

delete soft

delete wq

}

probe softirq.entry {

soft[cpu(), h,vec,action]++;

}

probe workqueue.execute {

wq[wq_thread, work_func]++

}

probe begin {

println("~")

}

下面執行i.stap 的結果:

==irq number:dev_name

87,eth0-0->1693

90,eth0-3->1263

95,eth1-3->746

92,eth1-0->703

89,eth0-2->654

==softirq cpu:h:vec:action

0:ffffffff81a83098:ffffffff81a83080:0xffffffff81461a00->8928

0:ffffffff81a83088:ffffffff81a83080:0xffffffff81084940->626

0:ffffffff81a830c8:ffffffff81a83080:0xffffffff810ecd70->614

16:ffffffff81a83088:ffffffff81a83080:0xffffffff81084940->225

16:ffffffff81a830c8:ffffffff81a83080:0xffffffff810ecd70->224

==workqueue wq_thread:work_func

ffff88083062aae0:ffffffffa01c53d0->10

ffff88083062aae0:ffffffffa01ca8f0->10

ffff88083420a080:ffffffff81142160->2

ffff8808343fe040:ffffffff8127c9d0->2

ffff880834282ae0:ffffffff8133bd20->1

下面是action對應的符号資訊:

addr2line -e /usr/lib/debug/lib/modules/2.6.32-431.20.3.el6.mt20161028.x86_64/vmlinux ffffffff81461a00

/usr/src/debug/kernel-2.6.32-431.20.3.el6/linux-2.6.32-431.20.3.el6.mt20161028.x86_64/net/core/dev.c:4013

打開這個檔案,我們發現它是在執行 static void net_rx_action(struct softirq_action *h)這個函數,而這個函數正是前文提到的,NET_RX_SOFTIRQ 對應的軟中斷處理程式。是以可以确認網卡的軟中斷在機器上分布非常不均,而且主要集中在CPU 0上。通過/proc/interrupts能确認硬中斷集中在CPU 0上,是以軟中斷也都由CPU 0處理,如何優化網卡的中斷成為了我們關注的重點。

優化政策

CPU親緣性

前文提到,丢包是因為隊列中的資料包超過了 netdev_max_backlog 造成了丢棄,是以首先想到是臨時調大netdev_max_backlog能否解決燃眉之急,事實證明,對于輕微丢包調大參數可以緩解丢包,但對于大量丢包則幾乎不怎麼管用,核心處理速度跟不上收包速度的問題還是客觀存在,本質還是因為單核進行中斷有瓶頸,即使不丢包,服務響應速度也會變慢。是以如果能同時使用多個CPU Core來進行中斷,就能顯著提高中斷處理的效率,并且每個CPU都會執行個體化一個softnet_data對象,隊列數也增加了。

中斷親緣性設定

通過設定中斷親緣性,可以讓指定的中斷向量号更傾向于發送給指定的CPU Core來處理,俗稱“綁核”。指令grep eth /proc/interrupts的第一列可以擷取網卡的中斷号,如果是多隊列網卡,那麼就會有多行輸出:

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

中斷的親緣性設定可以在 cat /proc/irq/${中斷号}/smp_affinity 或 cat /proc/irq/${中斷号}/smp_affinity_list 中确認,前者是16進制掩碼形式,後者是以CPU Core序号形式。例如下圖中,将16進制的400轉換成2進制後,為 10000000000,“1”在第10位上,表示親緣性是第10個CPU Core。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

那為什麼中斷号隻設定一個CPU Core呢?而不是為每一個中斷号設定多個CPU Core平行處理。我們經過測試,發現當給中斷設定了多個CPU Core後,它也僅能由設定的第一個CPU Core來處理,其他的CPU Core并不會參與中斷處理,原因猜想是當CPU可以平行收包時,不同的核收取了同一個queue的資料包,但處理速度不一緻,導緻送出到IP層後的順序也不一緻,這就會産生亂序的問題,由同一個核來處理可以避免了亂序問題。

但是,當我們配置了多個Core進行中斷後,發現Redis的慢查詢數量有明顯上升,甚至部分業務也受到了影響,慢查詢增多直接導緻可用性降低,是以方案仍需進一步優化。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

Redis程序親緣性設定

如果某個CPU Core正在處理Redis的調用,執行到一半時産生了中斷,那麼CPU不得不停止目前的工作轉而進行中斷請求,中斷期間Redis也無法轉交給其他core繼續運作,必須等處理完中斷後才能繼續運作。Redis本身定位就是高速緩存,線上的平均端到端響應時間小于1ms,如果頻繁被中斷,那麼響應時間必然受到極大影響。容易想到,由最初的CPU 0單核進行中斷,改進到多核進行中斷,Redis程序被中斷影響的幾率增大了,是以我們需要對Redis程序也設定CPU親緣性,使其與進行中斷的Core互相錯開,避免受到影響。

使用指令taskset可以為程序設定CPU親緣性,操作十分簡單,一句taskset -cp cpu-list pid即可完成綁定。經過一番壓測,我們發現使用8個core進行中斷時,流量直至打滿雙萬兆網卡也不會出現丢包,是以決定将中斷的親緣性設定為實體機上前8個core,Redis程序的親緣性設定為剩下的所有core。調整後,确實有明顯的效果,慢查詢數量大幅優化,但對比初始情況,仍然還是高了一些些,還有沒有優化空間呢?

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

通過觀察,我們發現一個有趣的現象,當隻有CPU 0進行中斷時,Redis程序更傾向于運作在CPU 0,以及CPU 0同一實體CPU下的其他核上。于是有了以下推測:我們設定的中斷親緣性,是直接選取了前8個核心,但這8個core卻可能是來自兩塊實體CPU的,在/proc/cpuinfo中,通過字段processor和physical id 能确認這一點,那麼響應慢是否和實體CPU有關呢?實體CPU又和NUMA架構關聯,每個實體CPU對應一個NUMA node,那麼接下來就要從NUMA角度進行分析。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

NUMA

SMP 架構

随着單核CPU的頻率在制造技術上的瓶頸,CPU制造商的發展方向也由縱向變為橫向:從CPU頻率轉為每瓦性能。CPU也就從單核頻率時代過渡到多核性能協調。

SMP(對稱多處理結構):即CPU共享所有資源,例如總線、記憶體、IO等。

SMP 結構:一個實體CPU可以有多個實體Core,每個Core又可以有多個硬體線程。即:每個HT有一個獨立的L1 cache,同一個Core下的HT共享L2 cache,同一個實體CPU下的多個core共享L3 cache。

下圖(摘自核心月談)中,一個x86 CPU有4個實體Core,每個Core有兩個HT(Hyper Thread)。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

NUMA 架構

在前面的FSB(前端系統總線)結構中,當CPU不斷增長的情況下,共享的系統總線就會因為資源競争(多核争搶總線資源以通路北橋上的記憶體)而出現擴充和性能問題。

在這樣的背景下,基于SMP架構上的優化,設計出了NUMA(Non-Uniform Memory Access)—— 非均勻記憶體通路。

記憶體控制器晶片被內建到處理器内部,多個處理器通過QPI鍊路相連,DRAM也就有了遠近之分。(如下圖所示:摘自CPU Cache)

CPU 多層Cache的性能差異是很巨大的,比如:L1的通路時長1ns,L2的時長3ns...跨node的通路會有幾十甚至上百倍的性能損耗。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

NUMA 架構下的中斷優化

這時我們再回歸到中斷的問題上,當兩個NUMA節點進行中斷時,CPU執行個體化的softnet_data以及驅動配置設定的sk_buffer都可能是跨node的,資料接收後對上層應用Redis來說,跨node通路的幾率也大大提高,并且無法充分利用L2、L3 cache,增加了延時。

同時,由于Linux wake affinity 特性,如果兩個程序頻繁互動,排程系統會覺得它們很有可能共享同樣的資料,把它們放到同一CPU核心或NUMA Node有助于提高緩存和記憶體的通路性能,是以當一個程序喚醒另一個的時候,被喚醒的程序可能會被放到相同的CPU core或者相同的NUMA節點上。此特性對中斷喚醒程序時也起作用,在上一節所述的現象中,所有的網絡中斷都配置設定給CPU 0去處理,當中斷處理完成時,由于wakeup affinity特性的作用,所喚醒的使用者程序也被安排給CPU 0或其所在的numa節點上其他core。而當兩個NUMA node進行中斷時,這種排程特性有可能導緻Redis程序在CPU core之間頻繁遷移,造成性能損失。

綜合上述,将中斷都配置設定在同一NUMA Node中,中斷處理函數和應用程式充分利用同NUMA下的L2、L3緩存、以及同node下的記憶體,結合排程系統的wake affinity特性,能夠更進一步降低延遲。

Redis 高負載下的中斷優化(基于京東redis叢集高負載下丢包案例分析)

參考文檔

  1. Intel 官方文檔
  2. Redhat 官方文檔

繼續閱讀