1
0

Add the protocol-comparison module

This commit is contained in:
2026-09-04 00:52:18 +05:30
parent 5224afdad2
commit d56c60824e
32 changed files with 1710 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# 1. The same use case, three ways
Next: [2. How they measure](02-benchmarks.md)
---
One application in this module serves the same two operations over gRPC, RSocket and a raw
WebSocket: fetch one quote, and stream N quotes. Same JVM, same heap, same
[`QuoteSource`](../src/main/java/com/ankurm/protocols/QuoteSource.java), same five fields.
That constraint is the point. Cross-protocol benchmarks published on the internet almost always
compare three different applications, and end up measuring three different serialisation
libraries, three JIT states and three thread pools.
## What each one is, in one sentence
| | gRPC | RSocket | Raw WebSocket |
|---|---|---|---|
| Transport | HTTP/2 | TCP, WebSocket, or others | HTTP/1.1 Upgrade |
| Schema | `.proto`, mandatory, code-generated | none required | none |
| Encoding here | protobuf | CBOR (Spring's default) | JSON |
| Interaction models | 4, declared in the `.proto` | 4, chosen by return type | whatever you invent |
| Demand signalling | none in the API | `request(n)` on the wire | none at all |
| Deadlines | per call, absolute, propagating | none built in | none |
| Browser client | needs grpc-web + a proxy | yes, over the WebSocket transport | yes, natively |
| Boot 4 starters | `spring-boot-starter-grpc-server` / `-client` | `spring-boot-starter-rsocket` | `spring-boot-starter-websocket` |
## The three server sides, side by side
**gRPC** ([`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java)) is
generated-class inheritance. There is no registration code: the generated `ImplBase` is a
`BindableService` and Boot registers every such bean.
```java
@Override
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
observer.onCompleted();
}
```
**RSocket** ([`RSocketQuoteController`](../src/main/java/com/ankurm/protocols/RSocketQuoteController.java))
is annotation routing, and the interaction model *is the return type*:
```java
@MessageMapping("quote")
public Mono<Quote> quote(String symbol) { ... } // request/response
@MessageMapping("quotes")
public Flux<Quote> quotes(StreamSpec spec) { ... } // request/stream
```
**Raw WebSocket** ([`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java))
is a `switch` on a string, because there is nothing else:
```java
switch (parts[0]) {
case "QUOTE" -> send(session, source.at(parts[1], 0));
case "STREAM" -> { for (int i = 0; i < count; i++) { send(...); } }
...
}
```
That third block is the honest summary of what a raw WebSocket gives you: a bidirectional pipe
for text or bytes. Everything above it &mdash; routing, correlation, errors, versioning, demand
&mdash; is yours to invent, and inventing it is how teams end up with a private, undocumented
protocol that only its authors can debug. STOMP exists to stop that, and is covered in
[`../sse-websocket`](../../sse-websocket/README.md).
## Boot 4 version notes
Boot 4.1.1's BOM pins **grpc-java 1.83.1** and **protobuf-java 4.35.1**, both deliberately older
than the newest releases on Maven Central. Overriding them independently is how you get a
`NoSuchMethodError` between grpc-java and its shaded Netty. Note that these have moved since Boot
4.1.0, which pinned 1.80.0 and 4.34.2 &mdash; so a `spring.grpc.*` guide written against 4.1.0 is
already describing different jars.
RSocket is **rsocket-java 1.1.5**, and `spring-boot-starter-rsocket` brings
`jackson-dataformat-cbor` on purpose: CBOR, not JSON, is Spring's default RSocket data mime type.
---
Next: [2. How they measure](02-benchmarks.md)

View File

@@ -0,0 +1,74 @@
# 2. How they measure
Previous: [1. Three protocols](01-three-protocols.md) &middot; Next: [3. Payload](03-payload.md)
---
## Read this before the numbers
Client and server are **the same process on one machine**, talking over loopback: two cores,
3.9&nbsp;GB, JDK 25, Boot 4.1.1. That has three consequences, and ignoring them makes the numbers
say the opposite of the truth.
1. **There is no network.** On a real link, a 35-byte message and an 83-byte message differ by
more than the framing cost, and protobuf's size advantage starts paying. Here it does not.
2. **A protocol that does less looks faster.** WebSocket wins both benchmarks below partly because
it has no flow control, no deadlines, no schema and no per-call metadata. That is a real cost
difference, and it is also exactly what you give up.
3. **Ratios, not absolutes.** Absolute microsecond figures from a two-core sandbox mean nothing
for your hardware. The ordering and the rough factors are what transfer.
Re-measured on every `./scripts/run-all.sh`, and re-measured **in their own JVM invocation**.
That is not tidiness. The back-pressure tests in [chapter 4](04-backpressure.md) leave unbounded
producers spinning, and running the throughput benchmark after them on a two-core box halved
every number — gRPC fell from 44 801 msgs/s to 18 477. If you take one operational lesson from
this module rather than a protocol one, let it be that: a benchmark that shares a machine with
anything is measuring the machine.
## Request/response
5&nbsp;000 sequential calls after 2&nbsp;000 warm-up
([`request-response.txt`](output/request-response.txt)):
```
gRPC n=5000 p50= 291.3 us p99= 1015.3 us mean= 324.4 us ~3,083 calls/s
RSocket n=5000 p50= 300.9 us p99= 1337.4 us mean= 350.9 us ~2,850 calls/s
WebSocket n=5000 p50= 124.3 us p99= 1447.8 us mean= 172.8 us ~5,786 calls/s
```
**gRPC and RSocket are the same speed.** Within noise across runs the two swap places; treat them
as indistinguishable for request/response on a warm connection.
**Raw WebSocket is about twice as fast at the median &mdash; and has the worst tail.** p50 of
124&nbsp;&micro;s against a p99 of 1&nbsp;448&nbsp;&micro;s is a 12x spread. gRPC's is 3.5x. The
median is cheap because a text frame over an already-open socket is nearly free; the tail is bad
because nothing is scheduling anything, so a GC pause or a scheduling hiccup lands on the request
undiluted.
If your SLO is a percentile rather than an average &mdash; and it should be &mdash; that table
does not say what a first glance suggests.
## Server streaming
50&nbsp;000 messages on one connection
([`stream-throughput.txt`](output/stream-throughput.txt)):
```
gRPC 50,000 msgs in 1.116 s = 44,801 msgs/s ( 22.32 us each)
RSocket 50,000 msgs in 0.702 s = 71,175 msgs/s ( 14.05 us each)
WebSocket 50,000 msgs in 0.459 s = 109,022 msgs/s ( 9.17 us each)
```
Same ordering, wider gaps: WebSocket about 2.4x gRPC, RSocket about 1.6x.
Now read the WebSocket row again with its harness in view. The client's inbox is an
`ArrayBlockingQueue` sized to hold **all fifty thousand messages**, because there was nothing else
to do with them &mdash; a raw WebSocket consumer that falls behind can buffer or drop, and those
are the only two options. WebSocket "won" this benchmark by buffering the entire stream in the
client's heap.
That is not a rhetorical point. It is the next chapter.
---
Previous: [1. Three protocols](01-three-protocols.md) &middot; Next: [3. Payload](03-payload.md)

View File

@@ -0,0 +1,56 @@
# 3. Payload: the one comparison that needs no benchmark
Previous: [2. Benchmarks](02-benchmarks.md) &middot; Next: [4. Back-pressure](04-backpressure.md)
---
Five fields &mdash; a symbol, a sequence number, two doubles and a timestamp &mdash; encoded three
ways ([`payload-sizes.txt`](output/payload-sizes.txt)):
```
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
JSON is 2.37x protobuf; CBOR is 1.91x protobuf
```
This is a property of the formats, not of a machine, so it is the one row of the comparison that
transfers to your hardware unchanged.
**Protobuf is 35 bytes because field names are integers.** `0a04 41 41 50 4c` is field 1, length
4, `AAPL`. There is no `"symbol"` on the wire at all &mdash; the name lives in the `.proto` file
that both sides compiled against. That is where the size comes from, and it is also the whole
argument about schemas: the saving and the coupling are the same fact.
**CBOR is 67 bytes and still writes the field names.** `6673796d626f6c` is a 6-character text
string, `symbol`. CBOR is binary JSON, not a schema format: it saves the punctuation and encodes
numbers compactly, and it keeps every key. So its advantage over JSON is real but modest, and it
does not require the coupling.
Concretely, on a stream of a million quotes: 35&nbsp;MB, 67&nbsp;MB, 83&nbsp;MB. On a loopback
that difference disappears into memory bandwidth &mdash; which is exactly why the benchmarks in
[chapter 2](02-benchmarks.md) do not reward it and a real link would.
Two practical notes:
- **Spring's RSocket default is CBOR**, not JSON, which is why
`spring-boot-starter-rsocket` brings `jackson-dataformat-cbor`. If you are debugging with
`tcpdump` and expecting to read your payloads, that is why you cannot. The mechanism is codec
ordering, printed by
[`RSocketDefaultsTest`](../src/test/java/com/ankurm/protocols/RSocketDefaultsTest.java):
```
encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder, JacksonCborEncoder, JacksonJsonEncoder]
bare RSocketStrategies.create() : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder]
```
CBOR sits *ahead of* JSON in the Boot-configured list, which is the whole of why it wins. Note
also that a bare `RSocketStrategies.create()` carries neither — an `RSocketRequester` built
without injecting Boot's strategies cannot encode your objects at all. And the class names
follow the Boot 4 rule: `JacksonCborEncoder`, not `Jackson2…`.
- **You can put protobuf on RSocket.** The encoding and the protocol are independent choices;
the row above is what each stack does *by default*, not what it is capable of.
---
Previous: [2. Benchmarks](02-benchmarks.md) &middot; Next: [4. Back-pressure](04-backpressure.md)

View File

@@ -0,0 +1,112 @@
# 4. Back-pressure: the measurement that decides it
Previous: [3. Payload](03-payload.md) &middot; Next: [5. Choosing](05-choosing.md)
---
Every protocol comparison eventually reduces to one question, and it is not throughput. It is:
> **When the consumer stops keeping up, what happens?**
[`BackPressureTest`](../src/test/java/com/ankurm/protocols/BackPressureTest.java) asks it the
simplest way there is. Each server offers an unbounded stream. Each client takes a hundred
messages and then does nothing. How many did the server produce?
The sweep over quiet periods is what makes the answer conclusive. A number that stays flat as the
wait grows means something bounded the producer. A number that keeps climbing means nothing did.
[`backpressure.txt`](output/backpressure.txt):
```
protocol quiet 0.5s quiet 1s quiet 2s
RSocket 100 100 100
gRPC 103,425 449,318 1,208,219
WebSocket 19,664 143,870 320,255
```
RSocket produced **exactly one hundred**, at every quiet period. gRPC produced over a million in
two seconds. Raw WebSocket, three hundred thousand. These are re-measured on every run and the
absolute counts move by tens of percent; the three orders of magnitude between the rows do not.
(The growth is faster than linear because the loop is still being JIT-compiled during the shortest
run. The shape is the finding, not the exponent.)
## Why RSocket stops
`request(n)` is a **frame on the wire**. When the client's subscriber calls `subscription.request(100)`,
a REQUEST_N frame carries that number to the server, and Reactor's `Flux.generate` is called
exactly a hundred times and then not again. Nothing blocks; there is no buffer to size and no
thread parked. The producer simply is not invoked.
That is what "Reactive Streams over the network" means, and it is the entire reason RSocket
exists. The same property is why RSocket's streaming throughput in
[chapter 2](02-benchmarks.md) is respectable rather than the highest: it is doing real work per
batch that the others are not doing at all.
## Why the other two do not
**Raw WebSocket has no mechanism.** The frame protocol has no notion of demand. The server writes
until something underneath refuses &mdash; a socket buffer, Spring's `sendBufferSizeLimit`, the
heap. Every one of those 320&nbsp;255 messages was serialised and written for a client that had
asked for a hundred.
**gRPC has HTTP/2 flow control, and it did not help.** This is the result I did not expect.
`StreamObserver.onNext` on a gRPC server **does not block**, so a plain server-streaming loop keeps
producing regardless of what the client is doing.
The documented answer is `ServerCallStreamObserver.isReady()`, and this module tests it rather than
citing it ([`grpc-isready.txt`](output/grpc-isready.txt)):
```
handler quiet 0.5s quiet 2s
plain onNext loop 258,145 1,421,778
isReady() checked before write 667,259 1,044,018
```
**It did not bound the producer.** The `isReady()` row is not lower than the plain one in any
useful sense &mdash; it produced 667&nbsp;thousand messages in half a second for a client that
wanted a hundred &mdash; and across runs it has come out both far above and far below the plain
loop, which is its own kind of answer. I
have not established whether the surplus accumulates in the server's outbound queue, in the client
transport, or is discarded after the deframer, and I am not going to assert a mechanism I have not
read the source for.
What the numbers do establish is narrower and still worth knowing: **`isReady()` is not a demand
signal**, and on a fast link with a stalled consumer it does not behave like one. If you are
streaming from gRPC to a consumer that can fall behind, the bound has to come from your own
design &mdash; a paged API, an explicit "send me more" message on a bidirectional stream, or a
`count` in the request as `StreamQuotes` uses.
## The honest caveat
A loopback connection is the worst case for this test in the fairest possible way: the wire drains
instantly, so nothing downstream ever pushes back. On a slow real link, TCP and the HTTP/2 window
*would* eventually stop the gRPC and WebSocket producers &mdash; by blocking a thread or by
filling a buffer, but they would stop.
That does not rescue them, because the failure mode you actually meet is not "the network is
slow", it is "one consumer of forty is slow", and a shared server discovers that as latency for
everyone else or as a heap that will not shrink. The difference RSocket makes is that the slow
consumer's slowness is *expressed*, in a number, that the producer can act on before anything
fills up.
## The other thing gRPC does not do for you
Abandoning a gRPC stream produced this, repeatedly, in the server log:
```
java.lang.IllegalStateException: Failed to close the call
Caused by: java.lang.IllegalStateException: call already closed
```
That is the cancellation story from
[the Spring gRPC article](https://ankurm.com/spring-grpc-spring-boot-4/) showing up in practice:
gRPC does not interrupt your thread when a client leaves, it sets a flag on the `Context`, and a
handler that touches an already-closed stream throws. The loop in
[`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java) checks
`Context.current().isCancelled()` and returns without calling `onCompleted` for exactly this
reason.
---
Previous: [3. Payload](03-payload.md) &middot; Next: [5. Choosing](05-choosing.md)

View File

@@ -0,0 +1,63 @@
# 5. Choosing
Previous: [4. Back-pressure](04-backpressure.md)
---
## The decision table
| If | Pick | Because |
|---|---|---|
| Service-to-service RPC, polyglot team, you want a schema | **gRPC** | The `.proto` is a contract other languages generate from. Deadlines are absolute and propagate. Boot 4 makes it a first-class starter. |
| A stream whose consumer can fall behind | **RSocket** | `request(n)` is the only demand signal that crosses the wire. 100 vs 1.3 million is not a tuning difference. |
| A browser is one of the peers | **WebSocket**, with STOMP on it | gRPC needs grpc-web and a proxy. RSocket over WebSocket works but the client ecosystem is thin. |
| Server pushes, client never speaks | **none of these** — use SSE | See [`../sse-websocket`](../../sse-websocket/README.md). |
| You want the lowest possible median latency and control both ends | **raw WebSocket** | It is fastest here because it does the least. Read the p99 before deciding that is what you want. |
| Long-lived bidirectional exchange with independent streams | **RSocket channel** or **gRPC bidi** | Both give you two independent streams; only RSocket gives each of them demand. |
## What each one really costs
**gRPC** costs a build step, a schema you must version, and an operational story for HTTP/2 through
your proxies. In exchange you get the best-supported cross-language RPC there is, and deadlines
&mdash; which is a bigger deal than it sounds, because an absolute deadline that propagates across
hops is the single most effective defence against one slow dependency taking down a system.
**RSocket** costs ecosystem. It is the least deployed of the three, the client libraries outside
Java and JavaScript are thin, and if you are not already reactive, `Mono` and `Flux` in your
service signatures is a real commitment. In exchange you get the only demand signalling in the
list, plus resumption and leasing.
**Raw WebSocket** costs everything you then invent: routing, correlation, error shape, versioning,
and the demand mechanism you will eventually need. That is the cost STOMP exists to prevent, and
if you find yourself designing a text protocol with a command word at the front &mdash; as
[`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java)
deliberately does &mdash; that is the signal to stop and use one.
## Should you use any of them?
Three cases where the answer is no:
- **Request/response between two services that both speak HTTP already.** A REST endpoint with a
`RestClient` and a timeout is fine, and everything in your organisation already knows how to
observe, cache, proxy and debug it. Reach for gRPC when the schema or the deadline propagation
is the thing you need, not because it benchmarks faster.
- **Streaming that is really batching.** If the consumer processes a page at a time anyway,
paginated HTTP has back-pressure for free: the consumer asks for the next page when it is ready.
That is `request(n)` with a URL.
- **Events that outlive the connection.** None of these three persist anything. A dropped
connection loses the messages in flight. If that matters, the answer is a broker &mdash;
[`../kafka-basics`](../../kafka-basics/README.md),
[`../rabbitmq`](../../rabbitmq/README.md), and the comparison in
[`../broker-comparison`](../../broker-comparison/README.md).
## And the benchmark caveat, one more time
Every number in this module was measured with client and server in one JVM on one machine. That
deletes the network, which is where protobuf's 35 bytes would pay and where the tail latencies
would look completely different. The measurement that does transfer is
[chapter 4](04-backpressure.md), because "how many did the server produce for a consumer that
wanted a hundred" is a property of the protocol and not of the link.
---
Previous: [4. Back-pressure](04-backpressure.md)

View File

@@ -0,0 +1,10 @@
=== unbounded stream; client takes 100, then goes quiet ===
protocol quiet 0.5s quiet 1s quiet 2s
RSocket 100 100 100
gRPC 103,425 449,318 1,208,219
WebSocket 19,664 143,870 320,255
RSocket is flat: the server produced exactly what request(n) asked for.
gRPC grows 11.7x between 0.5 s and 2 s; WebSocket grows 16.3x.
Growth that tracks the wait, rather than settling at a buffer size,
means nothing bounded the producer. (Growth is faster than linear
because the loop is still being JIT-compiled during the first run.)

View File

@@ -0,0 +1,4 @@
=== gRPC server streaming, client takes 100 then stops ===
handler quiet 0.5s quiet 2s
plain onNext loop 258,145 1,421,778
isReady() checked before write 667,259 1,044,018

View File

@@ -0,0 +1,5 @@
=== one Quote, five fields, three encodings ===
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
JSON is 2.37x protobuf; CBOR is 1.91x protobuf

View File

@@ -0,0 +1,4 @@
=== request/response, 5000 sequential calls after 2000 warm-up, loopback, JDK 25.0.4.1 ===
gRPC n=5000 p50= 302.0 us p99= 994.7 us mean= 335.1 us ~2,984 calls/s
RSocket n=5000 p50= 265.9 us p99= 1166.2 us mean= 311.9 us ~3,206 calls/s
WebSocket n=5000 p50= 122.8 us p99= 1893.9 us mean= 184.8 us ~5,410 calls/s

View File

@@ -0,0 +1,4 @@
=== Boot-configured RSocketStrategies ===
encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder, JacksonCborEncoder, JacksonJsonEncoder]
decoders : [StringDecoder, ByteBufferDecoder, ByteArrayDecoder, DataBufferDecoder, JacksonCborDecoder, JacksonJsonDecoder]
bare RSocketStrategies.create() encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder]

View File

@@ -0,0 +1,4 @@
=== server streaming, 50000 messages on one connection, loopback ===
gRPC 50,000 msgs in 1.341 s = 37,285 msgs/s ( 26.82 us each)
RSocket 50,000 msgs in 0.900 s = 55,580 msgs/s ( 17.99 us each)
WebSocket 50,000 msgs in 0.839 s = 59,630 msgs/s ( 16.77 us each)

View File

@@ -0,0 +1,6 @@
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.321 s -- in com.ankurm.protocols.PayloadSizeTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.76 s -- in com.ankurm.protocols.RequestResponseBenchmarkTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.29 s -- in com.ankurm.protocols.BackPressureTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.409 s -- in com.ankurm.protocols.GrpcIsReadyTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.474 s -- in com.ankurm.protocols.RSocketDefaultsTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.244 s -- in com.ankurm.protocols.StreamThroughputBenchmarkTest