2013-07-09 33 views
17

Là một phần trong nghiên cứu của tôi, tôi viết một máy chủ echo/IP echo tải cao trong Java. Tôi muốn phục vụ khoảng 3-4 nghìn khách hàng và xem các tin nhắn tối đa có thể mỗi giây mà tôi có thể rút ra khỏi nó. Kích thước thư là khá nhỏ - tối đa 100 byte. Công việc này không có mục đích thực tế - chỉ là một nghiên cứu.Java Máy chủ NIO TCP tải cao

Theo nhiều bản trình bày mà tôi đã thấy (các tiêu chuẩn HornetQ, các cuộc đàm phán LMAX Disruptor, vv), các hệ thống tải cao thế giới thực có xu hướng phục vụ hàng triệu giao dịch mỗi giây (tôi tin Disruptor đã đề cập khoảng 6 triệu và Hornet) - 8.5). Ví dụ: this post nói rằng có thể đạt tới 40 triệu MPS. Vì vậy, tôi lấy nó như là một ước tính sơ bộ về những gì phần cứng hiện đại nên có khả năng.

Tôi đã viết máy chủ NIO đơn luồng đơn giản nhất và chạy thử nghiệm tải. Tôi đã rất ngạc nhiên rằng tôi chỉ có thể nhận được khoảng 100k MPS trên localhost và 25k với mạng thực tế. Con số trông khá nhỏ. Tôi đã thử nghiệm trên Win7 x64, lõi i7. Nhìn vào tải CPU - chỉ có một lõi đang bận (được mong đợi trên một ứng dụng đơn luồng), trong khi phần còn lại ngồi nhàn rỗi. Tuy nhiên, ngay cả khi tôi tải tất cả 8 lõi (bao gồm cả ảo), tôi sẽ không có hơn 800 nghìn MPS - thậm chí không gần 40 triệu :)

Câu hỏi của tôi là: một mẫu điển hình để phục vụ một lượng lớn thông điệp cho khách hàng là gì ? Tôi có nên phân phối tải mạng qua một số ổ cắm khác nhau bên trong một JVM duy nhất và sử dụng một số loại cân bằng tải như HAProxy để phân phối tải cho nhiều lõi không? Hoặc tôi nên xem xét việc sử dụng nhiều Selectors trong mã NIO của tôi? Hoặc thậm chí có thể phân phối tải giữa nhiều JVM và sử dụng Chronicle để xây dựng một liên lạc giữa các quá trình giữa chúng? Sẽ thử nghiệm trên một hệ điều hành serverside thích hợp như CentOS làm cho một sự khác biệt lớn (có thể nó là Windows mà làm chậm mọi thứ xuống)?

Dưới đây là mã mẫu của máy chủ của tôi. Nó luôn luôn trả lời với "ok" cho bất kỳ dữ liệu đến. Tôi biết rằng trong thế giới thực, tôi cần phải theo dõi kích thước của thông điệp và chuẩn bị rằng một thông điệp có thể được chia nhỏ giữa nhiều lần đọc nhưng tôi muốn giữ mọi thứ siêu đơn giản ngay bây giờ.

public class EchoServer { 

private static final int BUFFER_SIZE = 1024; 
private final static int DEFAULT_PORT = 9090; 

// The buffer into which we'll read data when it's available 
private ByteBuffer readBuffer = ByteBuffer.allocate(BUFFER_SIZE); 

private InetAddress hostAddress = null; 

private int port; 
private Selector selector; 

private long loopTime; 
private long numMessages = 0; 

public EchoServer() throws IOException { 
    this(DEFAULT_PORT); 
} 

public EchoServer(int port) throws IOException { 
    this.port = port; 
    selector = initSelector(); 
    loop(); 
} 

private void loop() { 
    while (true) { 
     try{ 
      selector.select(); 
      Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator(); 
      while (selectedKeys.hasNext()) { 
       SelectionKey key = selectedKeys.next(); 
       selectedKeys.remove(); 

       if (!key.isValid()) { 
        continue; 
       } 

       // Check what event is available and deal with it 
       if (key.isAcceptable()) { 
        accept(key); 
       } else if (key.isReadable()) { 
        read(key); 
       } else if (key.isWritable()) { 
        write(key); 
       } 
      } 

     } catch (Exception e) { 
      e.printStackTrace(); 
      System.exit(1); 
     } 
    } 
} 

private void accept(SelectionKey key) throws IOException { 
    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel(); 

    SocketChannel socketChannel = serverSocketChannel.accept(); 
    socketChannel.configureBlocking(false); 
    socketChannel.setOption(StandardSocketOptions.SO_KEEPALIVE, true); 
    socketChannel.setOption(StandardSocketOptions.TCP_NODELAY, true); 
    socketChannel.register(selector, SelectionKey.OP_READ); 

    System.out.println("Client is connected"); 
} 

private void read(SelectionKey key) throws IOException { 
    SocketChannel socketChannel = (SocketChannel) key.channel(); 

    // Clear out our read buffer so it's ready for new data 
    readBuffer.clear(); 

    // Attempt to read off the channel 
    int numRead; 
    try { 
     numRead = socketChannel.read(readBuffer); 
    } catch (IOException e) { 
     key.cancel(); 
     socketChannel.close(); 

     System.out.println("Forceful shutdown"); 
     return; 
    } 

    if (numRead == -1) { 
     System.out.println("Graceful shutdown"); 
     key.channel().close(); 
     key.cancel(); 

     return; 
    } 

    socketChannel.register(selector, SelectionKey.OP_WRITE); 

    numMessages++; 
    if (numMessages%100000 == 0) { 
     long elapsed = System.currentTimeMillis() - loopTime; 
     loopTime = System.currentTimeMillis(); 
     System.out.println(elapsed); 
    } 
} 

private void write(SelectionKey key) throws IOException { 
    SocketChannel socketChannel = (SocketChannel) key.channel(); 
    ByteBuffer dummyResponse = ByteBuffer.wrap("ok".getBytes("UTF-8")); 

    socketChannel.write(dummyResponse); 
    if (dummyResponse.remaining() > 0) { 
     System.err.print("Filled UP"); 
    } 

    key.interestOps(SelectionKey.OP_READ); 
} 

private Selector initSelector() throws IOException { 
    Selector socketSelector = SelectorProvider.provider().openSelector(); 

    ServerSocketChannel serverChannel = ServerSocketChannel.open(); 
    serverChannel.configureBlocking(false); 

    InetSocketAddress isa = new InetSocketAddress(hostAddress, port); 
    serverChannel.socket().bind(isa); 
    serverChannel.register(socketSelector, SelectionKey.OP_ACCEPT); 
    return socketSelector; 
} 

public static void main(String[] args) throws IOException { 
    System.out.println("Starting echo server"); 
    new EchoServer(); 
} 
} 
+4

40 triệu giao dịch mỗi giây ** trên mỗi máy chủ ** ?! Họ phải trả lời bằng một byte đơn. –

+0

Tôi tin rằng đó là không có logic kinh doanh - chỉ có một loạt các thông điệp. Nhưng có, đó là những gì tôi đã nhìn thấy trong bài viết đó. Con số tuyệt vời. – Juriy

+1

Bạn không cần đợi OP_WRITE trước khi có thể viết. Bạn chỉ cần làm điều đó sau khi bạn đã có một chiều dài bằng không viết. Bạn không cần phải hủy khóa trước hoặc sau khi đóng kênh. – EJP

Trả lời

4

Logic của bạn xung quanh văn bản bị lỗi. Bạn nên cố gắng viết ngay lập tức bạn có dữ liệu để viết. Nếu số write() trả về số không, nó là rồi thời gian để đăng ký OP_WRITE, thử lại khi ghi kênh trở thành ghi và hủy đăng ký OP_WRITE khi ghi thành công. Bạn đang thêm một lượng lớn độ trễ ở đây. Bạn đang thêm nhiều thời gian chờ hơn bằng cách hủy đăng ký cho OP_READ trong khi bạn đang làm tất cả những điều đó.

+0

Cảm ơn bạn @EJP. bạn có thể vui lòng cung cấp cho tôi một số ví dụ không? cách đúng để đạt được hiệu suất tối đa bằng cách sử dụng NIO là gì? – FaNaJ

+0

Tôi đã làm tốt hơn việc cung cấp một số ví dụ. Tôi đã cung cấp cho bạn một nguyên tắc chung. Tránh độ trễ là một trong những cách để đạt được tối đa trong suốt. – EJP

+0

EJP, bạn có thể đề xuất một cách để giới thiệu lý do tại sao rời khỏi kênh ở chế độ OP_WRITE khi không có nhu cầu cấp bách để viết là ồ ạt tiềm ẩn? Tôi có thể tưởng tượng bộ xử lý phải sẵn sàng để đọc hoặc viết, nhưng không mong đợi điều này đáng chú ý ảnh hưởng đến hiệu suất. Tại sao quá chậm để kiểm tra xem bộ chọn có sẵn sàng ghi không? –

19
what is a typical pattern for serving massive amounts of messages to clients? 

Có rất nhiều mô hình có thể: Một cách dễ dàng để sử dụng tất cả các lõi mà không cần trải qua nhiều JVM là:

  1. Có một chủ đề duy nhất chấp nhận kết nối và đọc bằng một selector.
  2. Khi bạn có đủ byte để tạo thành một thư, hãy chuyển nó lên một lõi khác bằng cách sử dụng cấu trúc như bộ đệm vòng. Khung làm việc của Disruptor Java là một kết hợp tốt cho việc này. Đây là một mô hình tốt nếu việc xử lý cần thiết để biết thông điệp hoàn chỉnh là gì. Ví dụ, nếu bạn có một giao thức có độ dài tiền tố, bạn có thể đợi cho đến khi bạn nhận được số byte dự kiến ​​và sau đó gửi nó đến một luồng khác. Nếu việc phân tích cú pháp của giao thức là rất nặng thì bạn có thể áp đảo luồng đơn này ngăn không cho nó chấp nhận các kết nối hoặc đọc các byte của mạng.
  3. Trên (các) chuỗi công nhân của bạn, nơi nhận dữ liệu từ bộ đệm vòng, thực hiện quá trình xử lý thực tế.
  4. Bạn viết ra câu trả lời hoặc trên chuỗi công việc của bạn hoặc thông qua một số chuỗi tổng hợp khác.

Đó chính là ý chính của nó. Có nhiều khả năng hơn ở đây và câu trả lời thực sự phụ thuộc vào loại ứng dụng bạn đang viết. Một vài ví dụ là:

  1. Ứng dụng xử lý ảnh không có trạng thái CPU cho biết ứng dụng xử lý ảnh. Số lượng công việc CPU/GPU được thực hiện theo yêu cầu có thể cao hơn đáng kể so với chi phí phát sinh từ một giải pháp truyền thông liên hoàn rất ngây thơ. Trong trường hợp này một giải pháp dễ dàng là một loạt các chủ đề công nhân kéo công việc từ một hàng đợi duy nhất. Lưu ý rằng đây là một hàng đợi duy nhất thay vì một hàng đợi cho mỗi công nhân. Ưu điểm là điều này vốn đã được cân bằng tải. Mỗi công nhân hoàn thành công việc của mình và sau đó chỉ thăm dò hàng đợi nhiều người tiêu dùng sản xuất. Mặc dù đây là một nguồn tranh chấp, công việc xử lý hình ảnh (giây?) Sẽ đắt hơn nhiều so với bất kỳ phương án đồng bộ hóa nào.
  2. Ứng dụng IO thuần túy ví dụ: một máy chủ thống kê mà chỉ tăng một số quầy cho một yêu cầu: Ở đây bạn hầu như không có công việc nặng nhọc của CPU. Hầu hết công việc chỉ là đọc byte và ghi byte. Một ứng dụng đa luồng có thể không mang lại cho bạn lợi ích đáng kể ở đây. Trong thực tế nó thậm chí có thể làm chậm những thứ xuống nếu thời gian cần để xếp hàng là nhiều hơn thời gian cần thiết để xử lý chúng. Một máy chủ Java đơn luồng sẽ có thể bão hòa một liên kết 1G dễ dàng.
  3. Ứng dụng có trạng thái yêu cầu xử lý vừa phải, ví dụ: một ứng dụng kinh doanh điển hình: Ở đây mọi khách hàng đều có một số trạng thái xác định cách xử lý mỗi yêu cầu. Giả sử chúng tôi đi đa luồng kể từ khi xử lý là không tầm thường, chúng tôi có thể khởi tạo khách hàng cho một số chủ đề nhất định. Đây là một biến thể của kiến ​​trúc diễn viên:

    i) Khi khách hàng trước tiên kết nối băm cho công nhân. Bạn có thể muốn làm điều này với một số id khách hàng, để nếu nó ngắt kết nối và kết nối lại nó vẫn được gán cho cùng một nhân viên/diễn viên.

    ii) Khi chuỗi trình đọc đọc một yêu cầu hoàn chỉnh, hãy đưa nó vào bộ đệm vòng cho đúng nhân viên/diễn viên. Kể từ khi cùng một công nhân luôn luôn xử lý một khách hàng cụ thể tất cả các nhà nước nên được thread địa phương làm cho tất cả các logic xử lý đơn giản và đơn luồng.

    iii) Chuỗi công nhân có thể viết yêu cầu. Luôn luôn cố gắng chỉ cần viết một(). Nếu tất cả dữ liệu của bạn không thể được ghi ra chỉ sau đó bạn có đăng ký OP_WRITE không. Chuỗi công nhân chỉ cần thực hiện các cuộc gọi được chọn nếu thực sự có điều gì đó nổi bật. Hầu hết các bài viết nên chỉ thành công làm cho điều này không cần thiết. Bí quyết ở đây là cân bằng giữa các cuộc gọi chọn và bỏ phiếu cho bộ đệm vòng để có thêm yêu cầu. Bạn cũng có thể sử dụng một luồng văn bản duy nhất mà chỉ có trách nhiệm là viết các yêu cầu ra ngoài. Mỗi luồng công nhân có thể đặt câu trả lời của nó trên một bộ đệm vòng kết nối nó với luồng ghi đơn này. Một chuỗi các vòng ghi chủ đề ghi lại mỗi vòng đệm đến và ghi dữ liệu cho các máy khách. Một lần nữa báo trước về việc cố gắng viết trước khi chọn áp dụng như là lừa về cân bằng giữa nhiều bộ đệm vòng và các cuộc gọi chọn.

Như bạn chỉ ra có rất nhiều lựa chọn khác:

Should I distribute networking load over several different sockets inside a single JVM and use some sort of load balancer like HAProxy to distribute load to multiple cores?

Bạn có thể làm điều này, nhưng IMHO đây không phải là việc sử dụng tốt nhất cho một cân bằng tải. Điều này không mua cho bạn các JVM độc lập có thể tự thất bại nhưng có lẽ sẽ chậm hơn so với việc viết một ứng dụng JVM đơn lẻ là đa luồng.Bản thân ứng dụng có thể dễ viết hơn vì nó sẽ là một luồng đơn.

Or I should look towards using multiple Selectors in my NIO code? 

Bạn cũng có thể làm điều này. Hãy xem kiến ​​trúc Ngnix để biết một số gợi ý về cách thực hiện điều này.

Or maybe even distribute the load between multiple JVMs and use Chronicle to build an inter-process communication between them? Đây cũng là một tùy chọn. Biên niên sử cung cấp cho bạn một lợi thế mà các tập tin được ánh xạ bộ nhớ có khả năng phục hồi tốt hơn cho một quá trình bỏ ở giữa. Bạn vẫn nhận được rất nhiều hiệu suất vì tất cả các giao tiếp được thực hiện thông qua bộ nhớ chia sẻ.

Will testing on a proper serverside OS like CentOS make a big difference (maybe it is Windows that slows things down)? 

Tôi không biết điều này. Không chắc. Nếu Java sử dụng các API Windows nguyên bản một cách đầy đủ, thì nó không quan trọng nhiều. Tôi rất nghi ngờ về 40 triệu giao dịch/giây con số (không có một mạng không gian người dùng stack + UDP) nhưng các kiến ​​trúc tôi liệt kê nên làm khá tốt.

Những kiến ​​trúc này có khuynh hướng hoạt động tốt vì chúng là các kiến ​​trúc đơn ghi có sử dụng các cấu trúc dữ liệu dựa trên mảng được liên kết cho giao tiếp giữa các luồng. Xác định nếu đa luồng thậm chí là câu trả lời. Trong nhiều trường hợp, nó không cần thiết và có thể dẫn đến chậm lại.

Một khu vực khác để xem xét là các sơ đồ phân bổ bộ nhớ. Cụ thể, chiến lược cấp phát và sử dụng lại bộ đệm có thể dẫn đến những lợi ích đáng kể. Chiến lược sử dụng lại bộ đệm thích hợp phụ thuộc vào ứng dụng. Nhìn vào các đề án như phân bổ bộ nhớ buddy, phân bổ đấu trường vv để xem chúng có thể mang lại lợi ích cho bạn hay không. JVM GC thực hiện rất nhiều tiền phạt cho hầu hết các tải công việc mặc dù vậy luôn luôn đo lường trước khi bạn đi xuống tuyến đường này.

Thiết kế giao thức cũng có ảnh hưởng lớn đến hiệu suất. Tôi có xu hướng thích các giao thức có tiền tố có chiều dài hơn vì chúng cho phép bạn phân bổ các bộ đệm có kích thước phù hợp để tránh các danh sách các bộ đệm và/hoặc hợp nhất bộ đệm. Các giao thức có độ dài tiền tố cũng giúp dễ dàng quyết định khi nào cần chuyển giao một yêu cầu - chỉ cần kiểm tra num bytes == expected. Việc phân tích cú pháp thực tế có thể được thực hiện bởi luồng công nhân. Serialization và deserialization mở rộng vượt ra ngoài các giao thức có độ dài tiền tố. Các mẫu như mô hình trọng tải trên bộ đệm thay vì phân bổ giúp ở đây. Hãy xem SBE đối với một số nguyên tắc này.

Như bạn có thể hình dung toàn bộ luận văn có thể được viết ở đây. Điều này sẽ giúp bạn đi đúng hướng. Cảnh báo: Luôn đo lường và đảm bảo bạn cần hiệu suất nhiều hơn tùy chọn đơn giản nhất. Thật dễ dàng để bị hút vào một lỗ đen không bao giờ kết thúc cải tiến hiệu suất.

2

Bạn sẽ đạt được đỉnh vài trăm nghìn yêu cầu mỗi giây với phần cứng thông thường. Ít nhất đó là kinh nghiệm của tôi cố gắng để xây dựng các giải pháp tương tự, và the Tech Empower Web Frameworks Benchmark dường như đồng ý là tốt.

Cách tiếp cận tốt nhất, nói chung, phụ thuộc vào việc bạn có tải giới hạn io hoặc ràng buộc CPU hay không.

Đối với tải io (độ trễ cao), bạn cần phải thực hiện async io với nhiều chuỗi. Để có hiệu suất tốt nhất, bạn nên cố gắng hủy kết nối giữa các chủ đề càng nhiều càng tốt. Vì vậy, có một chuỗi chọn chuyên dụng và một threadpool khác để xử lý chậm hơn so với threadpool trong đó mỗi thread thực hiện lựa chọn hoặc xử lý, sao cho yêu cầu được xử lý bởi một chuỗi trong trường hợp tốt nhất (nếu io có sẵn ngay lập tức). Kiểu thiết lập này phức tạp hơn nhưng nhanh hơn và tôi không tin rằng bất kỳ khung công tác web async nào khai thác hoàn toàn điều này.

Đối với tải giới hạn CPU, một luồng cho mỗi yêu cầu thường là nhanh nhất, vì bạn tránh các công tắc ngữ cảnh.

Các vấn đề liên quan