How Banksalad Maintains Data Consistency When Building Games (feat. Optimistic Locking)
Key point
Ilhaera Kim Bangsael maintained consistency with optimistic locking based on update_version.
Details
Ilhaera Kim Bangsael is an idle app-tech game where users raise characters to earn SAL, and this SAL is an asset that can be converted into actual KRW. So the game had to guarantee data consistency in asset calculations while still keeping the game fun.
The core state, character_state, is updated through various paths: user APIs such as charging energy, leveling up, cleaning up trash, and using skills, manual adjustments from the back office, and future batch/async events. In this structure, a race condition directly leads to missing rewards or damaged trust, so concurrency control was treated not as a mere performance issue but as a matter of service safety mechanisms.
For locking methods, pessimistic locking, distributed locking, and optimistic locking were compared. Since conflict frequency was low, they didn't want to add extra infrastructure like Redis, and they needed to avoid an experience where the game flow gets interrupted due to DB waits, optimistic locking was ultimately chosen.
This choice was possible because the service structure itself was designed to absorb conflicts to a large extent.
- Reward calculation is not individual event accumulation but time-delta settlement based on the difference between the
last update timeand thecurrent time. - Even if a conflict causes one failure, the rewards from the previous period don't disappear; they get settled all at once at the next successful point.
- The server doesn't need to run excessive retry loops, allowing the system to build a self-correcting structure that converges to the latest state on its own.
The implementation added an update_version to the DB schema, performing atomic updates in the form of CAS (Compare-And-Swap) using the condition WHERE character_id = ? AND update_version = ? on update. character and character_state were separated to manage frequently changing dynamic data independently, and if numUpdated == 0, it was judged as a conflict and ErrConcurrentStateUpdate was returned.
In practice, it's noted that updated_at should not be used as a version field. Due to timestamp precision limits or clock skew, consecutive updates within the same millisecond can be missed, so an integer version field is essential for conflict detection.
In operations, replication lag and stale reads must be guarded against. For paths where consistency matters, the latest version must be read from the Primary DB, and event processing must also cross-check the DB version against the message version.
When handling conflicts, instead of unconditionally increasing retries, four principles are presented.
- Fresh Read: Re-read the latest version before retrying.
- Exponential backoff and jitter: Reduce chained conflicts caused by immediate retries.
- Idempotency: Safely handle duplicate requests based on
request_id. - Limit on maximum retry count: Repeated conflicts are treated as a signal of system contention, prompting consideration of a different response.
In the end, rather than a complex locking mechanism, this service leveraged the flow of the domain to secure both performance and consistency with the simplest optimistic locking suited to the service's characteristics. Since going into operation, it's reported that there have been 0 cases of data consistency issues.
This summary was generated automatically by AI. Check the original for the author's claims and context. Copyright belongs to the original author.
Our guide explains how the AI works. Report summary errors, attribution issues, or removal requests via Contact.