Background
I am working on an e-commerce website, my web server is written in ASP.NET MVC (C#, EF6) and I am using 3 instances of MySQL DB. These DBs are in a cluster (Galera Cluster) and they are automatically replicated.
The problem that I want to solve is load balancing the requests among these 3 instances of MySQL... according to MySQL documentation this can be easily achieved by using a multi-host connectionString
, like:
Server=host1, host2, host3; Database=myDataBase; Uid=myUsername; Pwd=myPassword;
Unfortunately this does not work for Connector/NET (there are multiple open bugs: 81650, 88962, more info here)
My solution
I have tried to implement a simple round robin load balancer for my application.
The aim is to split the multi-host connectionString
into multiple single-host connectionString
s, for example the above connectionString
would be converted to:
Server=host1; Database=myDataBase; Uid=myUsername; Pwd=myPassword; Server=host2; Database=myDataBase; Uid=myUsername; Pwd=myPassword; Server=host3; Database=myDataBase; Uid=myUsername; Pwd=myPassword;
The program would use a round robin algorithm to pick one of these connectionString
s. If one of the connections fails, I would ignore that connection for 5 mins and then it would be retried again.
The code
MySQLConnectionManager.cs
is going to split each of the multi-host connectionString
s into several single-host connectionString
s, note that I have 2 connectionString
s, one for DataDb
and one for LogDb
:
<add name="DataDb" connectionString="Server=1.2.3.10, 1.2.3.11; Database=db1; Uid=root; Pwd=..." providerName="MySql.Data.MySqlClient"/> <add name="LogDb" connectionString="Server=1.2.3.10, 1.2.3.11; Database=db2; Uid=root; Pwd=..." providerName="MySql.Data.MySqlClient"/>
It also has the LoadBalancer instance for each of of the connectionString
s.
MySQLConnectionManager.cs
public static class MySQLConnectionManager { private const string _server = "server"; // in this case, I want one load balancer for DataDb and one for LogDb private static Dictionary<string, DbLoadBalancer> _dbLoadBalancers = new Dictionary<string, DbLoadBalancer>(); static MySQLConnectionManager() { foreach (ConnectionStringSettings multiHostConnectionString in ConfigurationManager.ConnectionStrings) { DbConnectionStringBuilder dbConnectionStringBuilder = new DbConnectionStringBuilder(); var singleHostConnectionStrings = SplitMultiHostConnectionString(multiHostConnectionString.ConnectionString); if (singleHostConnectionStrings.Count > 0) { _dbLoadBalancers.Add(multiHostConnectionString.Name, new DbLoadBalancer(singleHostConnectionStrings)); } } } public static DbConnection GetDbConnection(string connectionStringName) { if (_dbLoadBalancers.ContainsKey(connectionStringName)) { return _dbLoadBalancers[connectionStringName].GetConnection(); } throw new Exception($"Could not find any `connectionString` with name = {connectionStringName}"); } private static List<string> SplitMultiHostConnectionString(string connectionString) { List<string> singleHostConnectionString = new List<string>(); DbConnectionStringBuilder dbConnectionStringBuilder = new DbConnectionStringBuilder() { ConnectionString = connectionString }; if (dbConnectionStringBuilder.ContainsKey(_server)) { string allServers = dbConnectionStringBuilder[_server] as string; string[] allServersArray = allServers.Split(','); foreach (string server in allServersArray) { DbConnectionStringBuilder builder = new DbConnectionStringBuilder(); builder.ConnectionString = dbConnectionStringBuilder.ConnectionString; builder[_server] = server.Trim(); singleHostConnectionString.Add(builder.ConnectionString); } } return singleHostConnectionString; } }
LoadBalancedConnectionString.cs
public class LoadBalancedConnectionString { public LoadBalancedConnectionString(string connectionString) { ConnectionString = connectionString; LastTimeConnectionWasUsed = DateTime.Now; IsDbAlive = true; } public string ConnectionString { get; set; } public DateTime LastTimeConnectionWasUsed { get; set; } public bool IsDbAlive { get; set; } }
DbLoadBalancer.cs
public class DbLoadBalancer { private List<LoadBalancedConnectionString> _loadBalancedConnectionStrings; private int _timeToIgnoreFailedDbInMin = 5; private LoadBalancerLogger _logger = null; public DbLoadBalancer(List<string> connectionStrings, string logPath = "") { _loadBalancedConnectionStrings = new List<LoadBalancedConnectionString>(); if (string.IsNullOrEmpty(logPath)) { _logger = new LoadBalancerLogger(logPath); } foreach (string connectionString in connectionStrings) { _loadBalancedConnectionStrings.Add(new LoadBalancedConnectionString(connectionString)); } } public DbConnection GetConnection() { LoadBalancedConnectionString lastUsedConnectionString = null; string message = string.Empty; string lastException = string.Empty; while (_loadBalancedConnectionStrings.Any(c => c.IsDbAlive == true)) { try { lastUsedConnectionString = _loadBalancedConnectionStrings.OrderBy(c => c.LastTimeConnectionWasUsed).FirstOrDefault(); MySqlConnection mySqlConnection = new MySqlConnection(lastUsedConnectionString.ConnectionString); mySqlConnection.Open(); lastUsedConnectionString.LastTimeConnectionWasUsed = DateTime.Now; lastUsedConnectionString.IsDbAlive = true; _logger.Write($"{DateTime.Now}: using: {lastUsedConnectionString.ConnectionString}"); return mySqlConnection; } catch (Exception ex) { lastUsedConnectionString.LastTimeConnectionWasUsed = DateTime.Now.AddMinutes(_timeToIgnoreFailedDbInMin); // don't use this connection for the next 5 mins lastUsedConnectionString.IsDbAlive = false; message += $"{DateTime.Now}: Failed to connect to DB using: {lastUsedConnectionString.ConnectionString}\n"; lastException = ex.Message; _logger.Write($"{DateTime.Now.ToString()}: Fail to connect to: {lastUsedConnectionString.ConnectionString}, marking this connection dead for {_timeToIgnoreFailedDbInMin} min"); } } _logger.Write($"{DateTime.Now}: All connections are dead."); throw new Exception($"Cannot connect to any of te DB instances. {message}\n Exception: {lastException}"); } }
This is working as expected...
I am using ASP.NET Identity and Ninject for DI, I had to do the following modification to ApplicationDbContext
:
[DbConfigurationType(typeof(MySqlEFConfiguration))] public class ApplicationDbContext : IdentityDbContext<ApplicationUser> { // this is how the constructor used to be // public ApplicationDbContext() : base("DataDb") // { // } public ApplicationDbContext() : base(MySQLConnectionManager.GetDbConnection("DataDb"), true/*contextOwnsConnection*/) { } public static ApplicationDbContext Create() { return new ApplicationDbContext(); } protected override void OnModelCreating(DbModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); } }
As you can see I am passing true for contextOwnsConnection
, I believe (for EF6) this means the connection should be terminated once DbContext
is disposed... this seems the logical choice for me, but would be great to get some feedback on this... I am worried to leave open connections around... or close the connections to soon (so far it has passed my initial tests)
And this is the Ninject
code, since this is a web project, I am using InRequestScope()
for all dependencies.
private static void RegisterServices(IKernel kernel) { kernel.Bind<ApplicationDbContext>().ToSelf().InRequestScope(); kernel.Bind<ICategoryRepository>().To<CategoryRepository>().InRequestScope(); ... }
Before trying to reinvent the wheel, I contacted Oracle and pointed out that this issue has been open since 2016... then I tried to use AWS Network Load Balancer but I did not manage to get it working see here... on the Galera documentation, I see many solutions use HAProxy, but this means I would have to set up 2 new servers (to have a fault tolerant HAProxy)... which seems too much investment in hardware... I also looked into using a DNS record with multiple IPs to split the load among different DBs... this apraoch seems a bit controversial... also I was also not sure if AWS CloudFront caching would cause any issues, so decided not to use it.
Note: I have put the final code in Github, in case anyone wants to use it.
Update
This issue has been fixed in Connector/NET 8.0.19, see: https://insidemysql.com/