diff --git a/docs/features/load-balancer/healthchecks.md b/docs/features/load-balancer/healthchecks.md index bd920c4..d37e18b 100644 --- a/docs/features/load-balancer/healthchecks.md +++ b/docs/features/load-balancer/healthchecks.md @@ -1,6 +1,7 @@ --- icon: material/lan-check --- + # Health checks All databases load balanced by PgDog are regularly checked with health checks. A health check is a small query that ensures the database is reachable and able to handle requests. @@ -90,7 +91,7 @@ The **default** value is **5 minutes** (`300_000` milliseconds). !!! note A database will not be placed back into the load balancer until it passes a health check again. - Make sure that `idle_healthcheck_timeout` is set to a lower setting than `ban_timeout`, so health checks have time to run before you expect the database to resume serving traffic. + Make sure that `idle_healthcheck_interval` is set to a lower value than `ban_timeout`, so health checks have time to run before you expect the database to resume serving traffic. ### False positives diff --git a/docs/features/load-balancer/index.md b/docs/features/load-balancer/index.md index eded425..f5e43f4 100644 --- a/docs/features/load-balancer/index.md +++ b/docs/features/load-balancer/index.md @@ -1,6 +1,9 @@ --- next_steps: - - ["Health checks", "/features/healthchecks/", "Ensure replica databases are up and running. Block offline databases from serving queries."] + - ["Health checks", "/features/load-balancer/healthchecks", "Ensure replica databases are up and running. Block offline databases from serving queries."] + - ["Replication & failover", "/features/load-balancer/replication-failover", "Replica lag detection and automatic traffic failover on replica promotion."] + - ["Transactions", "/features/load-balancer/transactions", "Handling of manually-started transactions."] + - ["Manual routing", "/features/load-balancer/manual-routing", "Overriding the load balancer using connection parameters or query comments."] icon: material/lan --- @@ -21,23 +24,23 @@ When a query is received by PgDog, it will inspect it using the native Postgres Applications don't have to manually route queries between databases or maintain several connection pools internally. !!! note "SQL compatibility" - PgDog's query parser is powered by the `pg_query` library, which extracts the Postgres native SQL parser directly from its source code. This makes it **100% compatible** with the PostgreSQL query language and allows PgDog to understand all valid Postgres queries. + PgDog's query parser is powered by the `pg_query` library, which extracts the Postgres native SQL parser directly from its source code. This makes it **100% compatible** with the PostgreSQL query language and allows PgDog to understand all valid PostgreSQL queries. ## Load distribution The load balancer is configurable and can distribute read queries between replicas using one of the following strategies: -* Round robin (default) -* Random -* Least active connections +* [Round robin](#round-robin) (default) +* [Random](#random) +* [Least active connections](#least-active-connections) -Choosing the best strategy depends on your query workload and the size of the databases. Each one has its pros and cons. If you're not sure, using the **round robin** strategy usually works well for most deployments. +Choosing the best strategy depends on your query workload and the size of the databases. Each strategy has its pros and cons. If you're not sure, using the **round robin** strategy usually works well for most deployments. ### Round robin -Round robin is often used in HTTP load balancers (e.g., nginx) to evenly distribute requests to hosts, in the same order as they appear in the configuration. Each database receives exactly one query before the next one is used. +Round robin is often used in HTTP load balancers (e.g., nginx) to evenly distribute requests between hosts, in the same order as they appear in the configuration. Each database receives exactly one transaction before the next one is used. -This algorithm makes no assumptions about the capacity of each database host or the cost of each query. It works best when all queries have similar runtime cost and replica databases have identical hardware. +This algorithm makes no assumptions about the capacity of each database or the cost of each query. It works best when all queries have similar runtime cost and replica databases have identical hardware. ##### Configuration @@ -50,7 +53,7 @@ load_balancing_strategy = "round_robin" ### Random -The random strategy sends queries to a database based on the output of a random number generator modulus the number of replicas in the configuration. This strategy assumes no knowledge about the runtime cost of queries or the size of database hardware. +The random strategy sends queries to a database based on the output of a random number generator modulus the number of replicas in the configuration. This strategy assumes no knowledge about the runtime cost of queries or the capacity of database hardware. This algorithm is often effective when queries have unpredictable runtime. By randomly distributing them between databases, it reduces hot spots in the replica cluster. @@ -87,6 +90,10 @@ The most common edge case is `SELECT FOR UPDATE` which locks rows for exclusive The load balancer detects this and will send this query to the primary database instead of a replica. +!!! note "Transaction required" + + `SELECT FOR UPDATE` is used inside manual [transactions](transactions.md) (i.e., started with `BEGIN`), which are routed to the primary database by default. + ### Write CTEs Some `SELECT` queries can trigger a write to the database from a CTE, for example: @@ -98,115 +105,7 @@ WITH t AS ( SELECT * FROM users INNER JOIN t ON t.id = users.id ``` -The load balancer recursively checks all of them and, if any CTE contains a query that could trigger a write, it will send the whole statement to the primary database. - -### Transactions - -All manual transactions are sent to the primary database by default. Transactions are started by sending the `BEGIN` command, for example: - -```postgresql -BEGIN; -INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING *; -COMMIT; -``` - -PgDog processes queries immediately upon receiving them, and since transactions can contain multiple statements, it isn't possible to determine whether the whole transaction writes to the database. Therefore, it is more reliable to send it to the primary database. - -!!! note "Replica lag" - While transactions are used to atomically change multiple tables, they can also be used to manually route `SELECT` queries to the primary database. For example: - - ```postgresql - BEGIN; - SELECT * FROM users WHERE id = $1; - COMMIT; - ``` - - - This is useful when the data in the table(s) has been recently updated and you want to avoid errors caused by replication lag. This often manifests as "record not-found"-style errors, for example: - - ``` - ActiveRecord::RecordNotFound (Couldn't find User with 'id'=9999): - ``` - - While sending read queries to the primary adds load, it is often necessary in real-time systems that are not equipped to handle replication delays. - - -#### Read-only transactions - -The PostgreSQL query language allows you to declare a transaction as read-only. This prevents it from writing data to the database. PgDog takes advantage of this property and will send such transactions to a replica database. - -Read-only transactions can be started with the `BEGIN READ ONLY` command, for example: - -```postgresql -BEGIN READ ONLY; -SELECT * FROM users WHERE id = $1; -COMMIT; -``` - -Read-only transactions are useful when queries depend on each other's results and need a consistent view of the database. Some Postgres database drivers allow this option to be set in the code, for example: - -=== "pgx (go)" - ```go - tx, err := conn.BeginTx(ctx, pgx.TxOptions{ - AccessMode: pgx.ReadOnly, - }) - ``` -=== "Sequelize (node)" - ```javascript - const tx = await sequelize.transaction({ - readOnly: true, - }); - ``` -=== "SQLAlchemy (python)" - Add `postgresql_readonly=True` to [execution options](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine.execution_options), like so: - ```python - engine = create_engine("postgresql://user:pw@pgdog:6432/prod") - .execution_options(postgresql_readonly=True) - ``` - -#### Primary-only connections - -If you need to override the load balancer routing decision and send a query (or all queries) to the primary, it's possible to do so by configuring the `pgdog.role` connection parameter. - -Configuring this connection parameter can be done at connection creation: - -=== "Connection URL" - ```bash - postgres://pgdog:pgdog@10.0.0.0:6432/database?options=-c%20pgdog.role%3Dprimary - ``` -=== "asyncpg (Python)" - ```python - conn = await asyncpg.connect( - user="pgdog", - password="pgdog", - database="pgdog", - host="10.0.0.0", - port=6432, - server_settings={ - "pgdog.role": "primary", - } - ) - ``` -=== "SQLAlchemy (Python)" - ```python - engine = create_async_engine( - "postgresql+asyncpg://pgdog:pgdog@10.0.0.0:6432/pgdog", - pool_size=20, - max_overflow=30, - pool_timeout=30, - pool_recycle=3600, - pool_pre_ping=True, - connect_args={"server_settings": {"pgdog.role": "primary"}}, - ) - ``` - -The following values are supported: - -| Value | Routing decision | -|-|-| -| `primary` | Queries are sent to the primary database only. | -| `replica` | Queries are load balanced between primary and replicas, depending on the value of the [`read_write_split`](../../configuration/pgdog.toml/general.md#read_write_split) setting. | - +The load balancer recursively checks CTEs and, if any of them contains a query that could trigger a write, it will send the whole statement to the primary database. ## Using the load balancer @@ -244,17 +143,6 @@ In case one of your replicas fails, you can configure the primary to serve read read_write_split = "include_primary_if_replica_banned" ``` -### Manual routing - -!!! note "New feature" - This feature was added in commit version [`c49339f`](https://github.com/pgdogdev/pgdog/commit/c49339f70db8be63b76ebb3aa0f31433c4266f21). If using this feature, make sure to run the latest version of PgDog. - -If your query is replica-lag sensitive (e.g., you are reading data that you just wrote), you can route it to the primary manually. The query router supports doing this with a query comment: - -```postgresql -/* pgdog_role: primary */ SELECT * FROM users WHERE id = $1 -``` - ## Learn more {{ next_steps_links(next_steps) }} diff --git a/docs/features/load-balancer/manual-routing.md b/docs/features/load-balancer/manual-routing.md new file mode 100644 index 0000000..8c02b37 --- /dev/null +++ b/docs/features/load-balancer/manual-routing.md @@ -0,0 +1,167 @@ +--- +icon: material/routes +--- + +# Manual routing + +PgDog's load balancer uses the PostgreSQL parser to understand and route queries between the primary and replicas. If you want more control, you can provide the load balancer with hints, influencing its routing decisions. + +This can be done on a per-query basis by using a comment, or on the entire client connection, with a session parameter. + +## Query comments + +If your query is replica-lag sensitive (e.g., you are reading data that you just wrote), you can route it to the primary manually. The load balancer supports doing this with a query comment: + +```postgresql +/* pgdog_role: primary */ SELECT * FROM users WHERE id = $1 +``` + +Query comments are supported in all types of queries, including prepared statements. If you're using the latter, the comments are parsed only once per client connection, removing any performance overhead of extracting them from the query. + + +## Parameters + +Parameters are connection-specific settings that can be set on connection creation to configure database behavior. For example, this is how ORMs and web frameworks control settings like `application_name`, `statement_timeout` and many others. + +The Postgres protocol doesn't have any restrictions on parameter names or values, and PgDog has access to them at connection creation. + +The following two parameters allow you to control which database is used for all queries on a client connection: + +| Parameter | Description | +|-|-| +| **`pgdog.role`** | Determines whether queries are sent to the primary database or the replica(s). | +| **`pgdog.shard`** | Determines which shard the queries are sent to. | + +The `pgdog.role` parameter accepts the following values: + +| Parameter value | Behavior | +|-|-| +| `primary` | All queries are sent to the primary database. | +| `replica` | All queries are load balanced between replica databases, and possibly the primary if [`read_write_split`](../../configuration/pgdog.toml/general.md#read_write_split) is set to `include_primary` (default). | + +The `pgdog.shard` parameter accepts a shard number for any database specified in [`pgdog.toml`](../../configuration/pgdog.toml/databases.md). + +### Setting the parameters + +Configuring parameters at connection creation is PostgreSQL driver-specific. Some of the common drivers and frameworks are shown below. + +#### Database URL + +Most PostgreSQL client libraries support the database URL format and can accept connection parameters as part of the URL. For example, when using `psql`, you can set the `pgdog.role` parameter like so: + +``` +psql postgres://user:password@host:6432/db?options=-c%20pgdog.role%3Dreplica +``` + +Depending on the environment, the parameters may need to be URL-encoded, e.g., `%20` is a space and `%3D` is the equals (`=`) sign. + +=== "asyncpg" + + [asyncpg](https://pypi.org/project/asyncpg/) is a popular PostgreSQL driver for asynchronous Python applications. It allows you to set connection parameters on connection setup: + + ```python + conn = await asyncpg.connect( + user="pgdog", + password="pgdog", + database="pgdog", + host="10.0.0.0", + port=6432, + server_settings={ + "pgdog.role": "primary", + } + ) + ``` + +=== "SQLAlchemy" + + [SQLAlchemy](https://www.sqlalchemy.org/) is a Python ORM, which supports any number of PostgreSQL connection drivers. For example, if you're using `asyncpg`, you can set connection parameters as follows: + + ```python + engine = create_async_engine( + "postgresql+asyncpg://pgdog:pgdog@10.0.0.0:6432/pgdog", + pool_size=20, + # [...] + connect_args={"server_settings": {"pgdog.role": "primary"}}, + ) + ``` + +=== "Rails / ActiveRecord" + + [Rails](https://rubyonrails.org/) and ActiveRecord support passing connection parameters in the `database.yml` configuration file: + + ```yaml + # config/database.yml + production: + adapter: postgresql + database: pgdog + username: user + password: password + host: 10.0.0.0 + options: "-c pgdog.role=replica -c pgdog.shard=0" + ``` + + These options are passed to the [`pg`](https://github.com/ged/ruby-pg) driver. If you're using it directly, you can create connections like so: + + ```ruby + require "pg" + + conn = PG.connect( + host: "10.0.0.0", + # [...] + options: "-c pgdog.role=primary -c pgdog.shard=1" + ) + ``` + +### Using `SET` + +The PostgreSQL protocol supports configuring connection parameters using the `SET` statement. This also works for configuring both `pgdog.role` and `pgdog.shard`. + +For example, to make sure all subsequent queries to be sent to the primary, you can execute the following statement: + +```postgresql +SET pgdog.role TO "primary"; +``` + +The parameter is persisted on the connection until it's closed or the parameter is changed with another `SET` statement. + +#### Inside transactions + +If you want to provide a transaction routing hint without affecting the rest of the connection, you can use `SET LOCAL` instead: + +```postgresql +BEGIN; +SET LOCAL pgdog.role TO "primary"; +``` + +In this example, all transaction statements (including the `BEGIN` statement) will be sent to the primary database. Whether the transaction is committed or reverted, the value of `pgdog.role` will be reset to its previous value. + +!!! note "Statement ordering" + To make sure PgDog intercepts the routing hint early enough in the transaction flow, make sure to send all hints _before_ executing actual queries. + + The following flow, for example, _will not_ work: + + ```postgresql + BEGIN; + SELECT * FROM users WHERE id = $1; + SET LOCAL pgdog.role TO "primary"; -- The client is already connected to a server. + INSERT INTO users (id) VALUES ($1); -- If connected to a replica, this will fail. + ``` + + + +## Disabling the parser + +In certain situations, the overhead of parsing queries may be too high, e.g., when your application can't use prepared statements. + +If you've configured the desired database role (and/or shard) for each of your application connections, you can disable the query parser in [pgdog.toml](../../configuration/pgdog.toml/general.md#query_parser): + +```toml +[general] +query_parser = "off" +``` + +Once it's disabled, PgDog will rely solely on the `pgdog.role` and `pgdog.shard` parameters to make its routing decisions. + +### Session state & `SET` + +The query parser is used to intercept and interpret `SET` commands. If the parser is disabled and your application uses `SET` commands to configure the connection, PgDog will not be able to guarantee that all connections have the correct session settings in [transaction mode](../transaction-mode.md). diff --git a/docs/features/load-balancer/replication-failover.md b/docs/features/load-balancer/replication-failover.md index e626612..9844a62 100644 --- a/docs/features/load-balancer/replication-failover.md +++ b/docs/features/load-balancer/replication-failover.md @@ -49,7 +49,7 @@ By default, PgDog will not query databases for their replication status. To enab lsn_check_delay = 0 # Run LSN check every second. -lsn_check_interval = 1_000 +lsn_check_interval = 1_000 ``` | Setting | Description | diff --git a/docs/features/load-balancer/transactions.md b/docs/features/load-balancer/transactions.md new file mode 100644 index 0000000..044d146 --- /dev/null +++ b/docs/features/load-balancer/transactions.md @@ -0,0 +1,74 @@ +--- +icon: material/swap-horizontal +--- + +# Transactions + +PgDog's load balancer is [transaction-aware](../transaction-mode.md) and will ensure that all statements inside a transaction are sent to the same PostgreSQL connection on just one database. + +To make sure all queries inside a transaction succeed, PgDog will route all manually started transactions to the **primary** database. + +## How it works + +Transactions are started by sending the `BEGIN` command, for example: + +```postgresql +BEGIN; +INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING *; +COMMIT; +``` + +PgDog processes queries immediately upon receiving them, and since transactions can contain multiple statements, it isn't possible to determine whether one of the statements won't write to the database. + +Therefore, it is more reliable to send the entire transaction to the primary database. + +### Read-only transactions + +The PostgreSQL query language allows you to declare a transaction as read-only, which prevents it from writing data to the database. PgDog can take advantage of this property and will send such transactions to a replica database. + +Read-only transactions are started with the `BEGIN READ ONLY` command, for example: + +```postgresql +BEGIN READ ONLY; +SELECT * FROM users WHERE id = $1; +COMMIT; +``` + +Read-only transactions are useful when queries need a consistent view of the database. Some Postgres database drivers allow this option to be set in the code, for example: + +=== "pgx (go)" + ```go + tx, err := conn.BeginTx(ctx, pgx.TxOptions{ + AccessMode: pgx.ReadOnly, + }) + ``` +=== "Sequelize (node)" + ```javascript + const tx = await sequelize.transaction({ + readOnly: true, + }); + ``` +=== "SQLAlchemy (python)" + Add `postgresql_readonly=True` to [execution options](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine.execution_options), like so: + ```python + engine = create_engine("postgresql://user:pw@pgdog:6432/prod") + .execution_options(postgresql_readonly=True) + ``` + +### Replication lag + +While transactions are used to atomically change multiple tables, they can also be used to manually route `SELECT` queries to the primary database. For example: + +```postgresql +BEGIN; +SELECT * FROM users WHERE id = $1; +COMMIT; +``` + +This is useful when the data in the table(s) has been recently updated and you want to avoid errors caused by replication lag. This often manifests as "record not-found"-style errors, for example: + +``` +ActiveRecord::RecordNotFound (Couldn't find User with 'id'=9999): +``` + +While sending read queries to the primary adds load, it is often necessary in real-time systems that are not equipped to handle replication delays.