2
\$\begingroup\$

I have SignalR app that publishes sent messages to Redis. My console app subscribe to channel where these messages are sent, deserializes it and saves in database.

Problem is with handling these events, as I want to make it in parallel. If many events are published, then the code crashes on opening database connection, because every thread want its own connection, so limit of physical connections is exceeded.

So I use SemaphoreSlim to use threads that are available on my machine (8 logical threads).

The problem is that the performance is very slow: about 300 inserts to DB per second. I think it's not due to my hardware, as it's pretty good (i7-9700F, 16GB RAM).

The application runs at WSL2 (Ubuntu-22.04).

That's code:

using Dapper;
using Messages.Save.Dtos;
using Microsoft.Extensions.Configuration;
using Npgsql;
using StackExchange.Redis;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;

var configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json")
    .Build();

const int MESSAGE_MAX_SENT_AT_OFFSET = 60;

RedisChannel UserChannel = new RedisChannel("*SignalRHub.MessagingHub:user:*", RedisChannel.PatternMode.Pattern);
RedisChannel GroupChannel = new RedisChannel("*SignalRHub.MessagingHub:group:*", RedisChannel.PatternMode.Pattern);

var connectionMultiplexer = ConnectionMultiplexer.Connect(configuration.GetConnectionString("Redis"));
var yugabyteConnectionString = configuration.GetConnectionString("YugabyteDB");

var semaphore = new SemaphoreSlim(Environment.ProcessorCount);

async void Handler(RedisChannel channel, RedisValue value)
{
    await semaphore.WaitAsync();
    await HandleMessageAsync(channel, value);
    semaphore.Release();
}

var subscriber = connectionMultiplexer.GetSubscriber();
await subscriber.SubscribeAsync(UserChannel, Handler);
await subscriber.SubscribeAsync(GroupChannel, Handler);

async Task HandleMessageAsync(RedisChannel channel, RedisValue value)
{
    if (channel.IsNullOrEmpty || value.IsNullOrEmpty)
        return;

    var saveMessageDto = ReadMessage(value);
    if (!ValidateMessage(saveMessageDto))
        return;

    using var connection = new NpgsqlConnection(yugabyteConnectionString);
    await connection.OpenAsync();

    var isUserGroupMember = await connection.QuerySingleAsync<bool>(@"SELECT EXISTS(
            SELECT 1 FROM ConversationMembers 
            WHERE ConversationId=@ConversationId AND UserId=@UserId)::boolean",
        new
        {
            saveMessageDto.ConversationId,
            UserId = saveMessageDto.SenderId
        });

    if (!isUserGroupMember)
        return;

    await connection.ExecuteAsync("INSERT INTO Messages(ConversationId, Message, SentAt) VALUES(@ConversationId, @Message, @SentAt);",
        new
        {
            saveMessageDto.ConversationId,
            saveMessageDto.Message,
            saveMessageDto.SentAt
        });
}

static SaveMessageDto? ReadMessage(RedisValue value)
{
    try
    {
        var str = value.ToString();

        var startIndex = str.IndexOf('{');
        var endIndex = str.LastIndexOf('}');

        var json = JsonNode.Parse(str.AsSpan(startIndex, endIndex - startIndex + 1).ToString())?.AsObject();
        if (json is null || !json.TryGetPropertyValue("arguments", out JsonNode? argsNode)
            || argsNode is not JsonArray arguments || !arguments.Any())
            return null;

        return JsonSerializer.Deserialize<SaveMessageDto>(arguments[0], new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });
    }
    catch
    {
        return null;
    }
}

static bool ValidateMessage([NotNullWhen(true)] SaveMessageDto? saveMessageDto)
{
    // some ultra lighweight validation (no impact on performance)
    return true;
}

await Task.Delay(Timeout.Infinite);
\$\endgroup\$
1
  • \$\begingroup\$ The current question title, which states your concerns about the code, is too general to be useful here. Please edit to the site standard, which is for the title to simply state the task accomplished by the code. Please see How to get the best value out of Code Review: Asking Questions for guidance on writing good question titles. \$\endgroup\$ Commented Jul 28, 2023 at 19:26

1 Answer 1

3
\$\begingroup\$

performance is very slow: about 300 inserts to DB per second.

There are two sections of code I have performance concerns about.

But first, it would really be worth your while to put a bunch of transaction data into a million-line .CSV and run this single-threaded using a single DB connection, to verify that DB performance is what you think it is. Do it with "small" and "large" BEGIN ... COMMIT transactions, as discussed below.


connection pooling

    using var connection = new NpgsqlConnection(yugabyteConnectionString);
    await connection.OpenAsync();

It's not clear to me that this does what you hope it does.

We hope it pulls from a connection pool and the await (almost always) immediately moves on since it finds a connection that is already open.

My concern is that perhaps this always allocates a new local ephemeral TCP port number, does {SYN, SYN-ACK, ACK} 3-way handshake, waits for the listening daemon to fork off a new worker, and then the await completes. It would be worth verifying, even with simple tools like netstat -an. Notice that this concern disappears completely when doing the proposed single-thread benching, as a single persistent connection would be used for that.

If you have N threads, I wonder if you'd like to allocate an array that stores N persistent connections. We happen to be holding a mutex (semaphore) upon entry, but with the array that's not even relevant since the i-th thread enjoys exclusive access to the i-th connection.


large transactions

    await connection.ExecuteAsync("INSERT INTO Messages(ConversationId, Message, SentAt) VALUES(@ConversationId, @Message, @SentAt);",

I don't know what your "is user group member" fraction might be, so having done a million SELECTs I'm not sure if we do nearly a million INSERTs or just some small number of them. Let's assume the fraction is 90%, so we usually do the INSERT.

I am concerned that perhaps you're effectively sending the following to the database:

  • BEGIN transaction
  • INSERT
  • COMMIT transaction

If so, then we would expect DB performance to plummet, perhaps to as low as a mere 300 tx/sec. Databases really want to see "large" transactions if we hope to achieve high throughput. The COMMIT says "persist this to disk and wait for that I/O to complete, so we can survive powerfail". (OTOH, sometimes we need tiny transactions for correctness. It's not clear that your use case fits into that, since you reported performance in terms of throughput rather than 95th-percentile latency.)

Here's an easy test you can shoehorn into the current code. For each input we currently do one SELECT and (roughly) one INSERT. Suppose we added a 2nd or even a 3rd useless "busy work" INSERT to the mix. Make a prediction about what reduced performance numbers you anticipate. Then do the experiment to verify.

What I'm shooting for is that each HandleMessageAsync call would do

  • BEGIN
  • SELECT
  • INSERT
  • INSERT
  • INSERT
  • COMMIT

If my guess about "short transactions" is correct, then you might consider adopting this approach:

  1. In addition to current threads, launch a single "writer" worker, which listens on a FIFO.
  2. Threads issue SELECTs to their heart's content. (Maybe without even holding the semaphore.)
  3. Rather than sending INSERTs directly, threads append such data to the FIFO.
  4. The writer thread bufers up K requests, perhaps ten or a hundred, and issues a "big" transaction of BEGIN / INSERT / INSERT ... / COMMIT. In the 1980's Oracle described this performance trick, applied across several unrelated users, as "group commit". Put e.g. a 30 msec timeout on it, so final messages won't get stalled when we go idle.
  5. Writer thread sends K wakeup notices, perhaps via K semaphores, letting those threads report "success" to their end users.

If we're concerned about same userid showing up repeatedly within a small time window, we can always finesse that with a local cache of items recently sent & received from the database.

\$\endgroup\$
7
  • \$\begingroup\$ 1. I think connections are pooled properly, because I have set Max Pool Size to 9 (because I can have up to 10 and one is reserved), and the connections count (info from database admin page) is always 9. 2. Generally, both SELECT and INSERT are done. The SELECT is just to prevent some kind of theoretically possible attack. 3. I'm not sure whether queue is good approach - it's messaging app, so messages should be saved as soon as possible for best user exprience (so count of sent messages or some timer doesnt decide when message is saved). \$\endgroup\$
    – Szyszka947
    Commented Jul 28, 2023 at 18:22
  • \$\begingroup\$ Refering to point 1, I also tested it Max Pool Size=300, but it doesnt result in better performance, as semaphore restrict number of connections to number of threads (at least I think it works like that). \$\endgroup\$
    – Szyszka947
    Commented Jul 28, 2023 at 18:24
  • \$\begingroup\$ I'm not sure how can I check pooling with netstat -an. I tried it but I don't see port 5433 (database port) anywhere. \$\endgroup\$
    – Szyszka947
    Commented Jul 28, 2023 at 18:26
  • 1
    \$\begingroup\$ It sounds like your postgres DB server is the same host as what your app is running on. Likely it's configured to locally use a unix domain socket (AF_UNIX). You need to decide what the relevant performance measure is, such as a user waiting 10msec or system throughput of several thousand messages per second. In any event, you really want to know what single-thread DB performance is, before trying to make sense of any threaded performance observations. \$\endgroup\$
    – J_H
    Commented Jul 28, 2023 at 19:05
  • 1
    \$\begingroup\$ I was just saying that a single-threaded DB client is pretty simple, it's easy to understand what it's doing and what the performance is. So you should see how your stack on your hardware performs single-threaded. The hope is that adding a 2nd or a 16th thread will improve performance. The reality is that sometimes it introduces thrashing, scheduling overhead, I/O contention, lock contention, and other artifacts that can work against good performance. We can only understand the artifacts if we're able to compare observed threaded performance against a baseline. You need baseline numbers. \$\endgroup\$
    – J_H
    Commented Jul 28, 2023 at 21:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.