Quartz on MySQL and MariaDB
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
MySQL and MariaDB are the databases most Spring Boot services start with, and both are supported by Quartz’s official JDBC job store: no special delegate is needed and a single DDL script
serves both servers. That simplicity hides a handful of details that decide whether the store is correct under load or only appears to work in a demo: which of the two MySQL scripts in the Quartz jar to
run (one of them predates InnoDB’s role as the default engine and must not be used), what isolation level Quartz really runs under, why a JobDataMap larger than 64 KB fails or, worse, is silently
truncated, and what happens to table names when the server runs with a different lower_case_table_names. This page covers them in that order, and every statement about what works or fails was produced by running it.
The generic concepts (what the QRTZ_ tables are, initialize-schema, @QuartzDataSource, pool sizing) are on
Job Stores & Persistence; the reference database page is
Quartz on PostgreSQL, and clustering in general is on
Clustering.
It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 and verified on 5 October 2026 on an Apple Silicon (arm64) Mac, with Docker 29.8.2 and Testcontainers 2.0.5. The servers were the images
mysql:9, which reported MySQL Community Server 9.7.2, and mariadb:11, which reported MariaDB 11.8.9 (both images are published for arm64 and ran natively, without emulation). The drivers were the ones Spring Boot 4.1.1 manages:
MySQL Connector/J 9.7.0 and MariaDB Connector/J 3.5.10. The migration tools were Flyway 12.4.0 (module flyway-mysql) and Liquibase 5.0.3. Spring Boot 3.5.16 manages the same Quartz 2.5.2, so everything about Quartz is identical there;
only the package of @QuartzDataSource and SchedulerFactoryBeanCustomizer differs (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), Spring Framework is 6.2.19, and the
managed driver and migration-tool versions are older. Other server versions (MySQL 8.4 LTS, MariaDB 10.x, Percona, Amazon Aurora, Google Cloud SQL) were not run; statements about them are labelled as such.
Status and dependencies
Status: official. The JDBC job store, the DDL scripts and the delegates are part of Quartz itself. Nothing on this page is community code. Quartz ships one delegate that fits both servers, StdJDBCDelegate, which is also what
Quartz uses when no delegate is configured, and one DDL script for MySQL and MariaDB together.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- choose the driver that matches your JDBC URL; either one worked against either server -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>2.0.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mariadb</artifactId>
<version>2.0.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.5</version>
<scope>test</scope>
</dependency>
</dependencies>
Source: Spring Boot: Quartz Scheduler. Keep only the driver you use; both are listed so the page can show both. A project that uses MariaDB needs mariadb-java-client only, and flyway-mysql also covers
MariaDB (see Flyway). The Testcontainers artifact names changed in 2.x: testcontainers-mysql and testcontainers-mariadb, classes org.testcontainers.mysql.MySQLContainer and org.testcontainers.mariadb.MariaDBContainer.
Two scripts in the jar, one to use
The Quartz 2.5.2 jar contains two MySQL scripts in org/quartz/impl/jdbcjobstore/: tables_mysql_innodb.sql and tables_mysql.sql. There is no MariaDB script. The names suggest that the second one is for MyISAM, the
engine MySQL used before 5.5, and its history agrees: the header of the InnoDB script says it is the original "modified to use INNODB instead of MYISAM". The two scripts were diffed and then each was run on both servers.
| Aspect | tables_mysql_innodb.sql |
tables_mysql.sql (legacy) |
|---|---|---|
|
|
none: the table gets the server’s |
Name columns ( |
|
|
Secondary indexes |
20 |
none are declared. The one index measured is the implicit index MySQL creates for a |
Foreign keys |
declared, and enforced by InnoDB |
declared; enforced only when the table is InnoDB (a MyISAM table accepts the clause and ignores it) |
Result of the Bookshelf run (four jobs, a dynamic job with a serialised |
passed on both servers |
passed on both servers, on InnoDB, without indexes |
Source: tables_mysql_innodb.sql (v2.5.2) and
tables_mysql.sql (v2.5.2). The columns are from running both scripts and reading information_schema.
The honest summary is that the legacy script is not "the MyISAM script" any more: on a modern server it creates InnoDB tables, but without the indexes and with the wider columns, and it is the wrong choice for a different reason than
the name suggests. To see what MyISAM would really do, the legacy script was forced onto it by appending ENGINE=MyISAM to every CREATE TABLE, and three things were measured:
-
On a database with the default
utf8mb4character set the script does not even run:Specified key was too long; max key length is 1000 byteson MySQL and the same message on MariaDB. MyISAM limits an index key to 1000 bytes and the primary key ofQRTZ_JOB_DETAILSis three name columns of up to 520 characters, which is 2080 bytes inutf8mb4. (The InnoDB script’sVARCHAR(190)columns total 2000 bytes, which InnoDB accepts; see Names, utf8mb4 and key lengths.) -
With
DEFAULT CHARSET=latin1added the script runs, and the Bookshelf run and a restart passed on both servers. -
The decisive difference is locking. Quartz locks a row of
QRTZ_LOCKSwithSELECT … FOR UPDATE. On InnoDB a second connection asking for the same row waited and failed after the 2000 ms lock wait that the test set, with error 1205 (Lock wait timeout exceeded). On MyISAM the second connection got the row in 5 ms, on both servers: MyISAM has table locks only,FOR UPDATEdoes not hold anything, and the mutual exclusion that Quartz’s cluster protocol relies on is gone (details in Transactions and locking). A two-node clustered run on the MyISAM tables showed no duplicate execution in 12 seconds. That is not evidence that it is safe, only that a short, light run does not hit the window; the lock demonstration is the evidence.
Spring Boot chooses the script from the JDBC URL. QuartzDataSourceScriptDatabaseInitializer maps the platform MYSQL and the platform MARIADB to the same name, mysql_innodb, so tables_mysql_innodb.sql is what runs for both
(source: QuartzDataSourceScriptDatabaseInitializer, which maps DB2 to db2_v95, MySQL and MariaDB to mysql_innodb and PostgreSQL to postgres). The measurement agrees: the databases created through jdbc:mysql URLs and through jdbc:mariadb URLs, against both servers, all had the
same 21 secondary indexes and VARCHAR(190) columns.
The delegate
There is no MySQL delegate in Quartz 2.5.2. The delegates in the jar are StdJDBCDelegate, PostgreSQLDelegate, MSSQLDelegate, HSQLDBDelegate, DB2v6Delegate, DB2v7Delegate, DB2v8Delegate, SybaseDelegate, PointbaseDelegate, CUBRIDDelegate,
GaussDBDelegate, CacheDelegate, WebLogicDelegate, and the two in the oracle sub-package. MySQL and MariaDB use StdJDBCDelegate, as the header of the InnoDB script says. It needs no property: in the run the scheduler reported
driverDelegateClass=null, and Quartz then uses StdJDBCDelegate. The contrast with the other engines is worth remembering, because the wrong delegate is the most common failure on them and cannot happen here:
PostgreSQL needs PostgreSQLDelegate because of its bytea column type, and SQL Server needs MSSQLDelegate because of its lock statement. Setting the property explicitly is harmless and makes the choice visible in review, and
the tests below do so.
application.yml
The production-shaped configuration keeps credentials in the environment, lets a migration tool own the schema, and sets the cluster properties. The keys were run with the container’s URL and credentials in place of the placeholders.
spring:
datasource:
url: ${DB_URL} # jdbc:mysql://db.example.internal:3306/bookshelf or jdbc:mariadb://...
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 13 # at least the Quartz thread count plus 3
flyway:
enabled: true
locations: classpath:db/migration/mysql
quartz:
job-store-type: jdbc
scheduler-name: BookshelfScheduler
jdbc:
initialize-schema: never # Flyway owns the schema; "always" would drop and recreate the tables on every start
properties:
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
org.quartz.jobStore.isClustered: "true"
org.quartz.jobStore.clusterCheckinInterval: "7500"
org.quartz.threadPool.threadCount: "10"
Source: Spring Boot: Quartz Scheduler. clusterCheckinInterval is shown at its default; the tests used 2000 ms to make failover quicker.
Two rules apply to this file. With initialize-schema: always (used throughout the tests as a development convenience) Boot runs the script on every start, and the script begins with DROP TABLE IF EXISTS statements, so every stored job and trigger is deleted at each restart.
Boot’s default, embedded, creates the schema only on an embedded database, so on MySQL or MariaDB nothing is created and the application must find its tables from a migration tool. The pool size follows the generic rule on the
Job Stores & Persistence page: Quartz’s worker threads plus three connections.
Flyway
The migration is the InnoDB script without its DROP TABLE lines. They are harmless on the empty database that a first migration runs against, but a migration that contains DROP TABLE statements is a trap for anyone who copies it as a template, so the page removes them.
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql \
| grep -v '^DROP TABLE' > src/main/resources/db/migration/mysql/V1__quartz_tables.sql
Source: Spring Boot: Database Initialization. The result is 168 lines and was run through Flyway 12.4.0 on both servers: flyway_schema_history recorded version 1, description quartz tables, success=true, and the Bookshelf run and a restart with
initialize-schema=never passed on MySQL 9.7.2 and on MariaDB 11.8.9. The file keeps the script’s # comment header, including an apostrophe, and Flyway’s MySQL parser handled it.
What Flyway printed is worth knowing before it frightens someone in a CI log. On MySQL 9.7 it warned Using MySQL 9.7 which is newer than the version Flyway has been verified with. The latest verified version of MySQL is 9.4., and on MariaDB 11.8 it warned
Using MariaDB 11.8 which is newer than the version Flyway has been verified with. The latest verified version of MariaDB is 11.7. Both are warnings, not failures. On MySQL, the migration also produced MySQL’s own warning Integer display width is deprecated and will be removed in a future release (error code 1681), once for each
BIGINT(13)-style column: the script still uses the display widths, and the server accepts them. The same migration tool and the same file work for both servers because Flyway’s MariaDB support lives in the flyway-mysql module
(Spring Boot 4.1.1’s dependency management lists flyway-mysql and no MariaDB module). Do not edit an applied migration. A later change, for instance the LONGBLOB change in BLOB handling, is a new V2__ file.
The general Flyway workflow and the choice between the tools are on Choosing a Migration Tool.
Liquibase
Liquibase can run the same script through sqlFile, and the Quartz jar also contains its own Liquibase changelog. Three variants were run on both servers, each against an empty database with initialize-schema=never, and all three passed: the changeset was recorded in DATABASECHANGELOG as EXECUTED,
the Bookshelf jobs fired and the state survived a restart.
databaseChangeLog:
- changeSet:
id: quartz-tables-1
author: bookshelf
changes:
- sqlFile:
path: db/changelog/quartz-tables-mysql-innodb.sql
splitStatements: true
endDelimiter: ;
Source: Liquibase: SQL changelogs. The quartz-tables-mysql-innodb.sql file next to it is the jar script without its DROP TABLE lines, the same file as the Flyway migration. Point spring.liquibase.change-log at the changelog if it is not at Boot’s default
classpath:/db/changelog/db.changelog-master.yaml.
| Variant | Secondary indexes | Name columns | Notes |
|---|---|---|---|
|
21 |
|
identical schema to the Flyway route |
|
21 |
|
works, but the script’s |
|
20 |
|
a different schema: changeset |
Source: liquibase.quartz.init.xml (v2.5.2). The columns are from information_schema after each run.
The bundled changelog is database-neutral (a blob_type property is BYTEA on PostgreSQL and BLOB elsewhere) and is included with <include file="org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml"/>, or in YAML as include: {file: …}. It works on MySQL and MariaDB, but it is
not the same schema as the InnoDB script: it names no engine (so the server default applies), keeps the 200-character names, and has 20 indexes. Pick one source of truth per environment and do not mix them. For the general workflow see
Liquibase: Getting Started.
Transactions and locking
This is the section that decides whether the store is correct. It was measured by switching on the server’s general log and reading every statement that one scheduler sent.
What one firing sends to the database
The log of a clustered node (default settings, acquireTriggersWithinLock=false, batch size 1) showed this sequence on one connection for the first firing of the overdue-loan trigger on MySQL. The statements are shortened; the order is as logged. On MariaDB the acquisition transaction was identical (with SET STATEMENT SQL_SELECT_LIMIT=1 instead of sql_select_limit); the firing transaction was not captured in that trace, but the log of the isolation runs showed the same SELECT … FROM QRTZ_LOCKS … FOR UPDATE statements, 23 to 28 per run.
Source: JobStoreSupport.java (v2.5.2), methods acquireNextTriggers and triggersFired. The log was read from mysql.general_log.
Two facts in the diagram differ from what many articles say. First, trigger acquisition does not take the TRIGGER_ACCESS lock by default: in acquireNextTriggers the lock name is TRIGGER_ACCESS only when acquireTriggersWithinLock is true or the batch size is greater than one, and
otherwise it is null. Acquisition is optimistic: the UPDATE … WHERE TRIGGER_STATE = 'WAITING' either changes the row or matches nothing, and Quartz then tries the next trigger. Second, the lock that serialises the nodes is taken when the trigger fires
(triggersFired), and by every store that uses the default locking, not only on MySQL. With InnoDB the optimistic update is safe because an UPDATE waits for the row lock of a concurrent update and then re-evaluates the condition on the latest committed row.
In the measurement every scheduled time was executed exactly once.
Row locks and the default delegate
Quartz’s default lock statement is SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = ? AND LOCK_NAME = ? FOR UPDATE, from StdRowLockSemaphore. selectWithLockSQL is not set for MySQL or MariaDB (Quartz overrides it only for MSSQLDelegate), so the default
is what runs, and no property is needed. The two lock rows, TRIGGER_ACCESS and STATE_ACCESS, are inserted by Quartz itself the first time they are requested, not by the DDL script: the integration test below asserts that two rows exist for the scheduler after startup.
Whether that statement locks anything depends on the engine, which is the whole reason the legacy script matters. The measurement used two connections and innodb_lock_wait_timeout=2 on the second one:
| Engine and server | Outcome |
|---|---|
InnoDB, MySQL 9.7.2 |
second connection waited 2091 ms, then failed with SQLState 40001, error 1205, |
InnoDB, MariaDB 11.8.9 |
waited 2018 ms, then failed with SQLState HY000, error 1205, the same message |
MyISAM, both servers |
the row came back in 5 ms: the statement did not wait, because |
Source: MySQL: Locking Reads and StdRowLockSemaphore.java (v2.5.2).
The server’s default innodb_lock_wait_timeout is 50 seconds on both. Quartz holds the lock row for the duration of one short transaction, so a wait close to that value means something else is wrong, typically a long transaction elsewhere that touches the Quartz tables, or a QuartzJobBean that started a transaction and holds it
while it works. Error 1205 in the logs is the symptom to search for.
The isolation level
Quartz runs its statements at whatever isolation level the connection has. The Quartz source sets one only when asked, through two properties: org.quartz.jobStore.txIsolationLevelSerializable
(in JobStoreSupport, applied to the connections it opens for itself) and org.quartz.jobStore.txIsolationLevelReadCommitted (in JobStoreCMT, which Spring’s LocalDataSourceJobStore extends, applied to the non-managed connections used by the scheduler thread). Both default to false.
The general log shows what each setting does on the wire:
| Setting | What the server received |
|---|---|
defaults |
no |
|
|
|
the same pairs with |
|
every pooled connection reported |
What does Quartz need? Its own protocol takes the isolation it needs from SELECT … FOR UPDATE on QRTZ_LOCKS, a locking read that sees the latest committed data and blocks on the lock regardless of the level, so the default REPEATABLE READ works, and so did READ COMMITTED
(the Hikari variant). In a 20-second stress run with three clustered nodes, ten worker threads each and 150 triggers repeating every second (3000 possible firings), the default and the Hikari READ COMMITTED variants both produced no InnoDB deadlock: the counter stayed at zero and the server never recorded a
"latest detected deadlock". There were 24 successful runs (12 on MySQL with the defaults and with READ COMMITTED, 4 on MySQL with the binary log disabled, 8 on MariaDB), and in 23 of them every scheduled time was executed exactly once. In one MySQL run with READ COMMITTED one scheduled time was recorded twice; it did not reproduce in the five later runs of that
variant, the details were not captured, and nothing else in the log explains it, so it is reported rather than explained. Treat it as one more reason to write idempotent jobs, which is advice that holds regardless of the database.
Throughput varied from a few hundred to about 3000 firings per run on MySQL and from about 2000 to 3000 on MariaDB, but the machine was shared with other test runs, so no speed comparison between the two servers is claimed.
The practical rule that follows: leave the isolation level alone. If a production system reports Deadlock found when trying to get lock; try restarting transaction (error 1213) in Quartz’s logs, the first thing to try is
spring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED for the Quartz data source, because READ COMMITTED takes fewer gap locks in InnoDB. That recommendation comes from InnoDB’s documented behaviour and from the measured fact that Quartz works at that level; it is not a fix for a deadlock that was reproduced here. The general discussion of
isolation and locking is on Transaction Isolation and Locking.
BLOB handling
JOB_DATA in QRTZ_JOB_DETAILS and QRTZ_TRIGGERS, CALENDAR in QRTZ_CALENDARS and BLOB_DATA in QRTZ_BLOB_TRIGGERS are BLOB columns in the script, and a MySQL or MariaDB BLOB holds at most 65,535 bytes (information_schema reported 65535 for all four). The default JobDataMap is a Java-serialised object, so a job
that carries a large payload, or a calendar that lists thousands of dates, can exceed it. The outcome depends on the server’s sql_mode:
| Mode | Outcome |
|---|---|
strict (the default of both servers: |
|
non-strict session |
|
Source: MySQL: The BLOB and TEXT Types and Quartz Tutorial: Lesson 3, More About Jobs and JobDetails.
The non-strict case needed effort to produce: both drivers re-enable strict mode on their own sessions. With MariaDB Connector/J sessionVariables=sql_mode=NO_ENGINE_SUBSTITUTION was enough. With MySQL Connector/J that setting alone left the session at
STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION and the error stayed, and jdbcCompliantTruncation=false had to be added. So an application that runs on defaults gets the loud failure; only a server or URL deliberately configured for old MySQL behaviour gets the silent corruption. The remedy is to widen the columns, which the script does not do:
ALTER TABLE QRTZ_JOB_DETAILS MODIFY JOB_DATA LONGBLOB NULL;
ALTER TABLE QRTZ_TRIGGERS MODIFY JOB_DATA LONGBLOB NULL;
ALTER TABLE QRTZ_CALENDARS MODIFY CALENDAR LONGBLOB NOT NULL;
Source: MySQL: The BLOB and TEXT Types. After these statements a 70,000-byte payload was stored and read back intact on both servers. Ship them as a V2__ Flyway migration or a second Liquibase changeset; they were run here as plain SQL, not through a migration tool. A better design is a small JobDataMap that carries an identifier (memberId, loanId) and loads the large data from
the business tables.
org.quartz.jobStore.useProperties=true is the other documented answer to serialisation: the JobDataMap is stored as Java properties text. It was run on both servers, with two results. The Bookshelf schedule failed at startup because its overdue job holds an int counter: JobDataMap values must be Strings when the 'useProperties' property is set. Key of offending value: runs.
Plain Quartz with string values stored #|#Mon Oct 05 12:32:03 CEST 2026|memberId=42| (the | marks line ends) and read it back, while a Long value failed with the same message. So useProperties removes Java serialisation (and its class-version fragility) at the cost of strings only, and it does not lift the 64 KB limit.
Names, utf8mb4 and key lengths
The name columns are 190 characters wide, not the 200 of the other databases' scripts, and SCHED_NAME is 120. Two measurements show what that means:
| Test | Result on both servers |
|---|---|
a job name of 190 characters |
stored |
a job name of 191 characters |
|
a scheduler name of 121 characters |
|
primary key of one |
all created (the widest key in the real schema is 2064 bytes, below the 3072-byte limit of |
the same key in row format |
created |
the same key in row format |
MySQL: |
Source: MySQL: InnoDB Limits and MySQL: InnoDB Row Formats. The tests created the tables in a utf8mb4 database on both servers.
The 190 is explained by the last two rows: in COMPACT and REDUNDANT row formats, the default in MySQL 5.6, an index column may use at most 767 bytes, and 191 characters of utf8mb4 (4 bytes each) is 764. The 190-character columns are therefore a safe choice for old servers, and today’s servers use DYNAMIC by default, where the 200-character
columns of the legacy script would also have fitted. The consequence for the application is the opposite of a problem on modern servers and a trap on old ones: a Quartz job or trigger name may be at most 190 characters, and a name generated from user input (a loan id, a book title) should be limited or hashed before it becomes a JobKey.
A scheduler name that long is not realistic.
Which character set is used does not matter to Quartz as long as it is a multi-byte-safe one: names are plain identifiers, and the collations of the two servers differ only in their defaults (see the MariaDB section).
Case sensitivity of table names
Quartz builds its SQL as the table prefix QRTZ_ plus the upper-case table name, for example QRTZ_JOB_DETAILS. Whether qrtz_job_details is the same table depends on lower_case_table_names, which is fixed when the server’s data directory is initialised. On both Linux images it was 0, case-sensitive. The test created tables in four ways:
| Setup | Outcome |
|---|---|
stock script, upper-case names, server 0 |
works (what every other run on this page used) |
every identifier lower-cased ( |
Quartz failed at startup: |
the same tables with |
failed: |
only the prefix lower-cased ( |
works |
server started with |
works, on MySQL and on MariaDB |
the same server, stock upper-case script |
works, and the tables are stored as |
The pitfall is a database dump taken on Windows or macOS (where the common default is 1 or 2, so the tables are stored lower-case) and restored on a Linux server with 0: the application finds no tables. Keep the stock upper-case names and the default prefix, or make the whole fleet use the same
lower_case_table_names from the start. On MySQL 8.0 and later the setting cannot be changed after initialisation, which is why the test passed it as a container start argument (a server configured differently was not available to test, so what happens when you change it on an existing server is taken from the documentation).
MariaDB differences
The same page applies to MariaDB; only these differences were observed.
| Aspect | MySQL (mysql:9) |
MariaDB (mariadb:11) |
|---|---|---|
DDL script |
|
the same file; there is no MariaDB script and Boot maps the platform to |
Delegate |
|
|
Default driver and URL |
MySQL Connector/J 9.7.0, |
MariaDB Connector/J 3.5.10, |
Cross-driver use |
|
|
Authentication plugin of the test user |
|
|
Server default collation |
|
|
|
64 MiB |
16 MiB |
Default |
|
|
Isolation variable |
|
both |
Row limit on trigger acquisition (log) |
|
|
Flyway warning |
|
|
Engines available |
InnoDB (default), MyISAM, and others |
InnoDB (default), MyISAM, Aria, and others |
Source: MariaDB: MariaDB versus MySQL, Compatibility and MariaDB: About MariaDB Connector/J. Every value is from the running containers.
None of these changes Quartz’s behaviour. The two that deserve a decision are the driver and the authentication.
The driver follows the server, not the other way round. All four pairs (MySQL Connector/J or MariaDB Connector/J, against mysql:9 or mariadb:11) ran the Bookshelf jobs, stored the serialised JobDataMap and survived a restart, and Boot picked tables_mysql_innodb.sql in each case. Use the driver your organisation supports for the server, and
let the URL scheme be consistent with it, because Boot derives the platform from the scheme. MariaDB Connector/J does not accept jdbc:mysql: URLs unless told to (its permitMysqlScheme option); this page did not test that option.
Authentication with MySQL 9 depends on TLS. The test user on mysql:9 uses caching_sha2_password, MySQL’s default. A fresh server has no cached password hash for a user, and a driver that connects without TLS then cannot complete the first login unless it may retrieve the server’s RSA public key. Measured after FLUSH PRIVILEGES
(which empties that cache): Connector/J with sslMode=DISABLED failed with Public Key Retrieval is not allowed; adding allowPublicKeyRetrieval=true connected; sslMode=REQUIRED connected over TLS_AES_128_GCM_SHA256; MariaDB Connector/J with sslMode=disable connected, and sslMode=trust connected over TLS. Before the FLUSH PRIVILEGES
the DISABLED connection also worked, because the earlier TLS login had cached the hash, which is exactly why the failure appears on a new deployment or after a restart of the server rather than on a developer machine. sslMode=VERIFY_CA failed against the container with CertPathValidatorException: Path does not chain with any of the trust anchors, as expected for a self-signed certificate: production URLs should use VERIFY_CA or VERIFY_IDENTITY
with the server’s real certificate. On MariaDB, whose test user uses mysql_native_password, every variant connected.
Clustering
Clustering works on both servers with the same configuration as on PostgreSQL: isClustered=true, instanceId=AUTO, identical properties on every node, and InnoDB tables. It was measured with clusterCheckinInterval=2000.
| Test | MySQL | MariaDB |
|---|---|---|
three schedulers in one JVM, 12 seconds, |
|
|
two nodes, 10 seconds (an earlier run of the same scenario) |
no duplicate executions, work shared |
no duplicate executions, work shared |
|
|
same, recovered after 9330 ms |
Source: Quartz: Configure Clustering with JDBC-JobStore. The runs are the Bookshelf cluster scenarios of the verification project: three in-JVM schedulers and a separate JVM killed with kill -9.
Three observations carry over from the other databases. Quartz does not balance load: the node that asks first wins, so one node ran every cacheRefresh firing. Failover takes about the check-in interval plus Quartz’s fixed 7.5 s margin plus one check-in cycle: with a 2-second interval the recovery took 9.3 to 9.5 seconds, so with the default 7.5-second interval expect
roughly twice that or more, and size the job’s idempotency accordingly. And the in-flight job is re-run only if requestsRecovery is true. The full explanation is on Clustering. As the Quartz documentation requires, the clocks of the nodes (and of the database host) must be synchronised, which these single-machine tests could not exercise.
Testcontainers tests for the Bookshelf jobs
The tests start the database, let Flyway create the tables from V1__quartz_tables.sql, start the Bookshelf service with the JDBC job store and a cluster configuration, and check that the jobs were stored in InnoDB tables, that one of them fires and that the node checked in. @ServiceConnection supplies the container’s
connection details to the Boot data source. The Bookshelf schedule is the one from Getting Started with Spring Boot, with a property that makes the cache refresh fire every second.
package bookshelf;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.mysql.MySQLContainer;
@SpringBootTest(properties = {
"bookshelf.fast=true",
"spring.flyway.enabled=true",
"spring.flyway.locations=classpath:db/migration/mysql",
"spring.quartz.job-store-type=jdbc",
"spring.quartz.jdbc.initialize-schema=never",
"spring.quartz.scheduler-name=BookshelfScheduler",
"spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO",
"spring.quartz.properties.org.quartz.jobStore.isClustered=true",
"spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate"
})
@Testcontainers
@DirtiesContext
class BookshelfMySqlIT {
@Container
@ServiceConnection
static final MySQLContainer MYSQL = new MySQLContainer("mysql:9");
@Autowired
Scheduler scheduler;
@Autowired
JdbcTemplate jdbc;
@Test
void bookshelfJobsAreStoredInInnoDbAndFire() throws Exception {
assertThat(scheduler.getMetaData().getJobStoreClass().getSimpleName()).isEqualTo("LocalDataSourceJobStore");
assertThat(scheduler.getMetaData().isJobStoreClustered()).isTrue();
// Flyway created the tables, Boot's initializer did nothing, the Bookshelf beans were stored on startup
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_JOB_DETAILS", Integer.class)).isEqualTo(4);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_TRIGGERS", Integer.class)).isEqualTo(4);
// every table is InnoDB, so SELECT ... FOR UPDATE on QRTZ_LOCKS really locks a row
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'QRTZ\\_%' AND engine <> 'InnoDB'",
Integer.class)).isZero();
// the one-second cache refresh fires and the store records it (PREV_FIRE_TIME is -1, not NULL, until the first fire)
await().atMost(Duration.ofSeconds(30)).untilAsserted(() ->
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM QRTZ_TRIGGERS WHERE TRIGGER_NAME = 'cacheRefreshTrigger' AND PREV_FIRE_TIME > 0",
Integer.class)).isEqualTo(1));
// this node checked in, and the two cluster lock rows exist
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_SCHEDULER_STATE WHERE SCHED_NAME = 'BookshelfScheduler'",
Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_LOCKS WHERE SCHED_NAME = 'BookshelfScheduler'",
Integer.class)).isEqualTo(2);
// the isolation level Quartz runs under is the server default
assertThat(jdbc.queryForObject("SELECT @@transaction_isolation", String.class)).isEqualTo("REPEATABLE-READ");
}
}
Source: Testcontainers: MySQL Module and Spring Boot: Testcontainers. The test was run on 5 October 2026 and passed.
The MariaDB test is the same file with three lines changed: the import org.testcontainers.mariadb.MariaDBContainer, the field static final MariaDBContainer MARIADB = new MariaDBContainer("mariadb:11"); and the class name BookshelfMariaDbIT. It was run and passed too (one test each, 8.7 s for MySQL and 10.8 s for MariaDB including the container start, in one Maven run).
What the runs established:
-
@ServiceConnectionworks with both 2.x container classes, so no@DynamicPropertySourceis needed. The Boot data source gotjdbc:mysql://orjdbc:mariadb://URLs from the containers'getJdbcUrl(), and the platform detection chose the InnoDB script (the migration made that moot). -
Every
QRTZ_table was InnoDB, the two cluster lock rows existed, and@@transaction_isolationwasREPEATABLE-READon both servers. -
@DirtiesContextmatters when two such classes run in one JVM. Without it Spring caches the first test’s application context, whose scheduler stays alive while its container is stopped: in the run without the annotation the second test’s log filled withFailed to validate connectionandmarked as brokenwarnings from the first context’s scheduler thread talking to a dead MariaDB. With the annotation the warnings were gone and both tests still passed. -
The Spring Boot 4.1 test project in this verification also had Liquibase on its classpath; if both Flyway and Liquibase are present, disable the one you do not use (
spring.liquibase.enabled=false) or Boot will look for a Liquibase changelog.
The wider setup (reuse, CI runners, @ServiceConnection) is on Spring Boot Integration Tests with Testcontainers, and how to test Quartz jobs in general is on Testing.
Business data and Quartz data on MySQL
If the business data is also on MySQL or MariaDB, the simplest layout is one Hikari data source shared with Quartz: a single DataSource bean is picked up for the job store automatically, and the jobs can then take part in Spring transactions through LocalDataSourceJobStore. With a second data source you need @QuartzDataSource
(and @QuartzTransactionManager for its own transaction manager), exactly as on the Job Stores & Persistence page; two data sources with no @Primary make Boot silently fall back to the RAM store, and @QuartzDataSource does not rescue that (mark the application’s data source @Primary and Quartz’s @QuartzDataSource). A dedicated database for the QRTZ_ tables keeps the
cluster’s lock rows out of the way of long business transactions and lets you give the Quartz user SELECT, INSERT, UPDATE and DELETE only on that database; the grants used in the tests were GRANT ALL ON <database>.* for convenience, and a narrower grant was not measured. Entities and repositories are in
Spring Data JPA, and the engine-level background is in the SQL databases section.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Jobs run twice, or clustering seems to work but is unsafe |
tables are MyISAM, so |
use |
Slow trigger acquisition, scans in |
the legacy |
run the InnoDB script (21 secondary indexes) |
|
MyISAM forced onto |
do not use MyISAM for Quartz; it also has no row locks |
|
old |
use |
|
serialised |
|
|
non-strict |
keep the default strict mode; widen the column; delete and re-create the damaged job |
|
lower-case table names restored onto a server with |
keep the stock upper-case names; align |
|
MySQL 9 |
use TLS ( |
Stored jobs and triggers vanish after every restart |
|
use |
|
Boot’s default |
run the migration, or |
|
a transaction elsewhere holds |
find and shorten the transaction; do not raise |
|
InnoDB’s lock ordering under load (not reproduced in 24 stress runs here) |
retry is built in; if frequent, try |
Job name over 190 characters fails |
|
limit or hash generated keys |
Flyway logs |
a server newer than the Flyway release |
a warning only; it ran without a problem here |
Warnings |
a cached Spring context whose container was stopped |
|
|
Boot does not load it; the properties come from |
put the keys in |
What changed
| Topic | Before | Now (Quartz 2.5.2, Boot 4.1.1) |
|---|---|---|
Which MySQL script |
articles say |
the legacy script has no |
MariaDB script |
forum answers suggest a MariaDB-specific script |
none exists; Boot maps MariaDB to |
Name length |
200 characters in the legacy script |
190 in the InnoDB script (the key-prefix limit of old row formats) |
Trigger acquisition locking |
articles describe |
with the defaults it is not; acquisition is an optimistic |
Isolation |
older guides tell you to set |
Quartz sets nothing by default and ran correctly at REPEATABLE READ and READ COMMITTED; no deadlock was reproduced |
Connector/J artifact and driver class |
artifact |
artifact |
MySQL default authentication |
|
|
Flyway |
one artifact for every database |
database modules since Flyway 10: |
Testcontainers |
|
|
Spring Boot 3.5.16 |
same Quartz 2.5.2 and the same behaviour |
only the package of the Quartz auto-configuration types and the managed driver and migration-tool versions differ |
The complete list of version changes and documentation discrepancies of the section is on Migration & What’s Changed.
Source: Quartz releases and the measurements on this page.
Where to go next
-
The generic job-store concepts: Job Stores & Persistence.
-
The reference database page, with the full cluster test: Quartz on PostgreSQL.
-
The other engines: Oracle, SQL Server and Embedded and Other SQL.
-
How a cluster shares work and recovers: Clustering.
-
Every
spring.quartz.*property: Spring Boot Configuration. -
Running this setup in production: Production & Best Practices.