2013-07-09 13 views
17

W ramach moich badań piszę na serwerze Java o wysokim obciążeniu, serwer echo TCP/IP. Chcę obsłużyć około 3-4 tys. Klientów i zobaczyć maksymalną możliwą liczbę wiadomości na sekundę, którą mogę wycisnąć. Rozmiar wiadomości jest dość mały - do 100 bajtów. Ta praca nie ma żadnego praktycznego celu - tylko badania.Serwer Java NIO o dużym obciążeniu TCP

Zgodnie z wieloma prezentacjami, jakie widziałem (testy porównawcze HornetQ, LMAX Disruptor itp.), Rzeczywiste systemy o dużym obciążeniu obsługują miliony transakcji na sekundę (uważam, że Disruptor wspomniał o 6 milach i - 8,5). Na przykład, this post stwierdza, że ​​możliwe jest osiągnięcie do 40M MPS. Tak więc potraktowałem to jako przybliżoną ocenę tego, do czego powinien mieć nowoczesny sprzęt.

Napisałem najprostszy jednoniciowy serwer NIO i uruchomiłem test obciążenia. Byłem trochę zaskoczony, że mogę dostać tylko około 100k MPS na localhost i 25k z rzeczywistą siecią. Liczby wyglądają na małe. Testowałem na Win7 x64, core i7. Patrząc na obciążenie procesora - tylko jeden rdzeń jest zajęty (co jest oczekiwane w aplikacji jednowątkowej), podczas gdy reszta pozostaje bezczynna. Jednak nawet jeśli załaduję wszystkie 8 rdzeni (w tym wirtualne), nie będę miał więcej niż 800k MPS - nawet blisko 40 milionów :)

Moje pytanie brzmi: jaki jest typowy wzorzec służący do przesyłania ogromnych ilości wiadomości do klientów ? Czy powinienem dystrybuować ładowanie sieci przez kilka różnych gniazd wewnątrz pojedynczej maszyny JVM i użyć jakiegoś systemu równoważenia obciążenia, takiego jak HAProxy, aby rozdzielić obciążenia na wiele rdzeni? Czy powinienem spojrzeć w kierunku używania wielu selektorów w moim kodzie NIO? A może nawet rozłożyć obciążenie między wiele maszyn JVM i używać Kroniki do budowania komunikacji między procesami między nimi? Czy testowanie na odpowiednim systemie operacyjnym, takim jak CentOS, robi dużą różnicę (może to Windows spowalnia działanie)?

Poniżej znajduje się przykładowy kod mojego serwera. Zawsze odpowiada "ok" na wszystkie przychodzące dane. Wiem, że w prawdziwym świecie musiałbym śledzić rozmiar wiadomości i być przygotowanym na to, że jedna wiadomość może być podzielona na wiele odczytów, ale na razie chciałbym zachować rzeczy super proste.

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 milionów transakcji na sekundę ** na serwer ** ?! Muszą odpowiadać jednym bajtem. –

+0

Uważam, że było to bez logiki biznesowej - tylko w obie strony wiadomości. Ale tak, to właśnie widziałem w tym poście. Niesamowite liczby. – Juriy

+1

Nie musisz czekać na OP_WRITE, zanim będziesz mógł pisać. Trzeba to zrobić tylko po napisaniu o zerowej długości. Nie trzeba anulować klucza przed lub po zamknięciu kanału. – EJP

Odpowiedz

4

Twoja logika pisania jest błędna. Powinieneś spróbować zapisać natychmiast, gdy masz dane do napisania. Jeśli write() zwraca zero, to jest , następnie czas na rejestrację dla OP_WRITE, ponów próbę zapisu, gdy kanał stanie się zapisywalny, i wyrejestruj dla OP_WRITE, gdy zapis się powiedzie. Dodajesz tutaj ogromną ilość opóźnień. Dodajesz jeszcze więcej opóźnień, wyrejestrowując dla OP_READ, gdy robisz to wszystko.

+0

Dziękuję @ EJP. czy mógłbyś podać mi kilka przykładów? jaki jest właściwy sposób osiągnięcia maksymalnej wydajności za pomocą NIO? – FaNaJ

+0

Zrobiłem lepiej niż podałem kilka przykładów. Podałem ogólną zasadę. Unikanie opóźnień jest jednym ze sposobów osiągnięcia maksimum w całym tekście. – EJP

+0

EJP, czy możesz zasugerować sposób, aby dowiedzieć się, dlaczego opuszczenie kanału w trybie OP_WRITE, gdy nie ma pilnej potrzeby pisania, jest bardzo utajone? Mogę sobie wyobrazić, że procesor musi być gotowy do czytania lub pisania, ale nie spodziewał się, że to zauważalnie wpłynie na wydajność. Dlaczego tak wolno sprawdza, czy selektor jest gotowy do zapisu? –

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

Istnieje wiele możliwych wzorów: Łatwym sposobem na wykorzystanie wszystkich rdzeni, bez przechodzenia przez wielu JVMs jest:

  1. mieć jeden wątek przyjmowania połączeń i odczytać za pomocą selektora.
  2. Gdy masz wystarczającą liczbę bajtów, aby utworzyć pojedynczą wiadomość, przekaż ją do innego rdzenia, używając konstruktu takiego jak bufor pierścieniowy. Architektura Disruptor Java jest do tego odpowiednia. Jest to dobry wzór, jeśli przetwarzanie potrzebne do zrozumienia, co jest kompletną wiadomością, jest lekkie. Na przykład, jeśli masz protokół z prefiksami długości, możesz poczekać, aż uzyskasz oczekiwaną liczbę bajtów, a następnie wysłać je do innego wątku. Jeśli parsowanie protokołu jest bardzo ciężkie, możesz przytłoczyć ten pojedynczy wątek, uniemożliwiając mu akceptowanie połączeń lub odczytywanie bajtów sieci.
  3. W wątkach roboczych, które odbierają dane z bufora pierścieniowego, wykonaj faktyczne przetwarzanie.
  4. Zapisujesz odpowiedzi w wątkach roboczych lub w innym wątku agregatora.

To jest sedno tego. Jest tu znacznie więcej możliwości, a odpowiedź naprawdę zależy od rodzaju aplikacji, którą piszesz. Kilka przykładów:

  1. CPU ciężki bezpaństwowcem aplikacja powiedzieć aplikacji przetwarzania obrazu. Ilość pracy CPU/GPU na żądanie będzie prawdopodobnie znacznie wyższa niż narzut generowany przez bardzo naiwne rozwiązanie komunikacji między wątkami. W tym przypadku łatwym rozwiązaniem jest garść wątków roboczych, które ciągną pracę z pojedynczej kolejki. Zwróć uwagę, że jest to pojedyncza kolejka zamiast jednej kolejki na pracownika. Zaletą jest to, że jest to z natury zrównoważone obciążenie. Każdy robotnik kończy swoją pracę, a następnie odpytuje kolejkę wielu użytkowników jednego producenta. Mimo że jest to źródło niezgody, przetwarzanie obrazu (sekundy?) Powinno być znacznie droższe niż jakakolwiek alternatywa synchronizacji.
  2. Czysta aplikacja IO np. serwer statystyk, który po prostu zwiększa niektóre liczniki dla żądania: Tutaj prawie nie ma ciężkiej pracy CPU. Większość prac polega tylko na czytaniu bajtów i pisaniu bajtów. Wielowątkowa aplikacja może nie zapewniać tutaj istotnych korzyści. W rzeczywistości może nawet spowolnić działanie, jeśli czas potrzebny do umieszczenia w kolejce elementów jest dłuższy niż czas potrzebny na ich przetworzenie. Pojedynczy wątkowany serwer Java powinien być w stanie łatwo nasycić łącze 1G.
  3. Stateful applications które wymagają umiarkowanych ilości przetwarzania, np. typowa aplikacja biznesowa: tutaj każdy klient ma określony stan, który określa sposób obsługi każdego żądania. Zakładając wielowątkowość, ponieważ przetwarzanie nie jest trywialne, możemy powiązać klientów z określonymi wątkami. Jest to wariant architektury aktora:

    i) Kiedy klient po raz pierwszy łączy haszyk z robotnikiem. Możesz to zrobić za pomocą jakiegoś identyfikatora klienta, tak aby po rozłączeniu i ponownym połączeniu był nadal przypisany do tego samego pracownika/aktora.

    ii) Gdy wątek czytnika odczyta pełne żądanie, umieść go w buforze pierścieni dla właściwego pracownika/aktora. Ponieważ ten sam pracownik zawsze przetwarza określonego klienta, cały stan powinien być wątkiem lokalnym, dzięki czemu cała logika przetwarzania jest prosta i jednowątkowa.

    iii) Wątek roboczy może wypisać żądania. Zawsze staraj się po prostu zrobić write(). Jeśli wszystkie twoje dane nie mogą być zapisane tylko wtedy, zarejestruj się w OP_WRITE. Wątek roboczy musi tylko wywoływać wybrane połączenia, jeśli faktycznie jest coś wybitnego. Większość pisze powinna po prostu odnieść sukces, czyniąc to niepotrzebnym. Sztuką jest tutaj balansowanie pomiędzy wybranymi połączeniami i odpytywanie bufora pierścienia w poszukiwaniu kolejnych żądań. Możesz także użyć jednego wątku pisarskiego, którego jedynym obowiązkiem jest pisanie żądań. Każdy wątek roboczy może umieścić odpowiedzi na buforze pierścieniowym łączącym go z tym pojedynczym wątkiem zapisującym. Pojedynczy round-robin z pisarzem odpytuje każdy przychodzący bufor pierścieniowy i zapisuje dane do klientów. Znowu zastrzeżenie dotyczące próbowania zapisu przed wyborem stosuje się tak samo jak trik dotyczący równoważenia wielu buforów pierścieniowych i wybranych wywołań.

Jak podkreślają, istnieje wiele innych opcji:

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?

Można to zrobić, ale IMHO to nie jest najlepsze wykorzystanie dla równoważenia obciążenia. To kupi ci niezależne maszyny JVM, które mogą zawieść na własną rękę, ale prawdopodobnie będą wolniejsze niż pisanie pojedynczej aplikacji JVM wielowątkowej.Sama aplikacja może być jednak łatwiejsza do napisania, ponieważ będzie pojedyncza.

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

Możesz to również zrobić. Przejrzyj architekturę Ngnix, aby uzyskać wskazówki, jak to zrobić.

Or maybe even distribute the load between multiple JVMs and use Chronicle to build an inter-process communication between them? Jest to również opcja. Chronicle daje przewagę, że pliki mapowane w pamięci są bardziej odporne na proces rzucania w środku. Nadal masz dużo wydajności, ponieważ cała komunikacja odbywa się poprzez pamięć współdzieloną.

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

Nie wiem o tym. Mało prawdopodobne. Jeśli Java w pełni wykorzystuje natywne interfejsy API systemu Windows, nie powinno to mieć większego znaczenia. Mam duże wątpliwości co do liczby 40 milionów transakcji/sekundę (bez stosu sieci użytkownika + UDP), ale architektury, które wymieniłem, powinny być całkiem dobre.

Architektury te mają tendencję do czynienia dobrze, ponieważ są to architektury jednopisowe, które wykorzystują struktur danych o ograniczonej macierzy do komunikacji między wątkami. Ustal, czy wielowątkowa jest nawet odpowiedzią. W wielu przypadkach nie jest to konieczne i może prowadzić do spowolnienia.

Innym obszarem, na który należy zwrócić uwagę, są schematy przydzielania pamięci. W szczególności strategia przydzielania i ponownego wykorzystywania buforów może przynieść znaczne korzyści. Odpowiednia strategia ponownego wykorzystania bufora zależy od aplikacji. Spójrz na schematy, takie jak przydział pamięci znajomych, alokacja areny itp., Aby sprawdzić, czy mogą one przynieść ci korzyści. JVM GC radzi sobie świetnie przy większości obciążeń roboczych, więc zawsze mierz zanim zejdziesz w dół.

Projekt protokołu ma również duży wpływ na wydajność. Staram się preferować protokoły z prefiksami długości, ponieważ pozwalają one przydzielać bufory o odpowiednich rozmiarach, unikając list buforów i/lub łączących się buforów. Protokoły z prefiksami długości ułatwiają także decyzję, kiedy przekazać żądanie - wystarczy sprawdzić kod: num bytes == expected. Rzeczywiste parsowanie może być wykonane przez wątek pracowników. Serializacja i deserializacja wykracza poza protokoły o długości prefiksowanej. Pomagają tutaj takie wzorce jak wzorce masy w buforach zamiast przydziałów. Spójrz na SBE dla niektórych z tych zasad.

Jak można sobie wyobrazić, można tu napisać cały traktat. To powinno cię skierować we właściwym kierunku. Ostrzeżenie: zawsze mierz i upewnij się, że potrzebujesz większej wydajności niż najprostsza opcja. Łatwo jest wciągnąć się w niekończącą się czarną dziurę ulepszeń wydajności.

2

Będziesz osiągał szczyt kilkuset tysięcy żądań na sekundę przy użyciu zwykłego sprzętu. Przynajmniej to jest moje doświadczenie, próbując zbudować podobne rozwiązania, i wydaje się, że zgadza się również the Tech Empower Web Frameworks Benchmark.

Najlepsze podejście, ogólnie, zależy od tego, czy masz obciążenia związane z io-bound lub cpu.

W przypadku obciążeń związanych z io (duże opóźnienie) należy wykonać asynchroniczne wywoływanie z wieloma wątkami. Aby uzyskać najlepszą wydajność, należy próbować unieważnić przekazywanie między wątkami w jak największym stopniu. W związku z tym posiadanie dedykowanego wątku selektora i innej puli wątków do przetwarzania jest wolniejsze niż posiadanie wątku, w którym każdy wątek dokonuje wyboru lub przetwarzania, tak że żądanie jest obsługiwane przez pojedynczy wątek w najlepszym przypadku (jeśli io jest natychmiast dostępny). Tego typu konfiguracja jest bardziej skomplikowana dla kodu, ale szybka i nie wierzę, że jakikolwiek asynchroniczny framework sieciowy w pełni go wykorzystuje.

W przypadku obciążeń jednostkowych jeden wątek na żądanie jest zwykle najszybszy, ponieważ unika się przełączeń kontekstu.

Powiązane problemy