The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
11 KiB
Markdown
185 lines
11 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a05-f029-for-update-skip-locked
|
|
title: SKIP LOCKED 로 잡은 조정 작업을 두 번째 연결이 그대로 받는다
|
|
topic: state-ownership-and-concurrency
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a05-f029-for-update-skip-locked
|
|
evidenceCapturedOn: 2026-09-02
|
|
assets:
|
|
- key: a05-f029-for-update-skip-locked
|
|
file: ../../../final/evidence/rendered/a05-f029-for-update-skip-locked.svg
|
|
- key: a05-f029-for-update-skip-locked-probe
|
|
file: ../../../final/evidence/rendered/a05-f029-for-update-skip-locked-probe.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a05-f029-for-update-skip-locked.txt
|
|
- ../../../final/evidence/raw/a05-f029-for-update-skip-locked-probe.txt
|
|
source:
|
|
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §90 이다. 등급은 P2 이고, 잠금 수명과 작업 수명이 다르다는 판정과 두 작업자 시나리오 탐침이 그 절에 있다. 그 절도 수정으로 짧은 청구 쪽을 택한다.
|
|
- 표에 소유자와 리스 열이 아예 없다는 것, 같은 패키지의 전달 큐 청구문과의 대조, 그리고 두 도달 조건은 이 기록에서 확인했다.
|
|
---
|
|
|
|
# SKIP LOCKED 로 잡은 조정 작업을 두 번째 연결이 그대로 받는다
|
|
|
|
조정 작업 청구는 `FOR UPDATE SKIP LOCKED` 로 끝나는 SELECT 하나다. 트랜잭션도 열지 않고 표에 소유자나 리스를 쓰지도 않으므로, 그 조회가 끝나면서 잠금도 사라진다. 같은 패키지의 전달 큐는 같은 잠금 구문 뒤에 `UPDATE` 를 붙여 청구를 행에 남긴다.
|
|
|
|
## 관계
|
|
|
|
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
|
이 사례가 위반하는 규칙이다.
|
|
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
|
수정 방향이 기대는 구조다.
|
|
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
|
|
조건을 where 절에 전부 넣고 갱신 결과로 판단하라는 규칙이다.
|
|
|
|
## 문제
|
|
|
|
청구 저장소의 javadoc 은 잠금 구문을 쓰는 이유를 전달 큐와 같다고 적는다. 같은 표를 폴링하는 두 작업자가 같은 작업을 둘 다 가져가면 안 된다는 것이다.
|
|
|
|
## 결론
|
|
|
|
청구 메서드는 질의 하나를 실행하고 끝난다. 그 클래스에서 트랜잭션을 여는 애너테이션도 포트 호출도 찾을 수 없다. 자동 커밋이면 SELECT 종료와 함께 암묵 커밋이 걸리고 행 잠금도 그 자리에서 풀린다.
|
|
|
|
표 쪽에도 남길 자리가 없다. 조정 작업 표의 열은 다음 확인 시각과 시도 횟수와 마지막 결과뿐이고, 이 표를 건드리는 마이그레이션은 만든 것과 색인 하나와 완결성 검사용 이름 리터럴이 전부다.
|
|
|
|
작업자도 전체 구간을 감싸지 않는다. 만기 조회로 목록을 받은 뒤, 제공자를 다녀오고 나서야 상태를 쓴다.
|
|
|
|
실제 PostgreSQL 16 에서 자동 커밋 연결 둘이 그 문장을 차례로 실행했다. 사이에 정산은 없다. 둘 다 같은 작업을 받았고 행 상태는 그대로다. 겹쳐 실행하지 않아도 재현된다는 것이 잠금이 이미 사라졌다는 뜻이다.
|
|
|
|
같은 패키지의 전달 큐는 다르게 한다. 이쪽은 잠금 구문을 CTE 로 감싸고 한 문장 안에서 소유자·리스 만료·펜스를 갱신하며 상태를 배송 중으로 바꾼다. 청구가 행에 남는 시점이 잠금이 풀리기 전이다. 조정 쪽에서만 그 쓰기가 생략됐다.
|
|
|
|
여기서 일어나는 일이 전송이 아니므로 판정을 한 단계 낮췄다. 깨지는 것은 javadoc 이 적어 둔 계약 쪽이다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
데이터베이스 : PostgreSQL 16.15, 실제 실행
|
|
확인 방식 : 청구 메서드와 작업자의 경계 선언 확인, 표를 건드리는 마이그레이션 전수, 실제 PostgreSQL 에서 두 연결로 같은 문장 실행
|
|
소스 수정 : x
|
|
|
|
ca-skeleton.notification.platform.enabled 가 참이고 모드가 SERVING 이며 인스턴스가 둘 이상인 배포다. 출하 기본값이 거짓이고, 프로세스가 하나면 스케줄러가 자기 겹침을 막는다.
|
|
|
|
## 재현 조건
|
|
|
|
1. 청구 저장소의 javadoc 과 청구 문장, 그리고 그것을 실행하는 메서드를 읽는다.
|
|
2. 그 클래스와 작업자 클래스의 트랜잭션 경계 선언을 센다.
|
|
3. 조정 작업 표의 열과, 그 표를 건드리는 마이그레이션을 전부 나열한다.
|
|
4. 같은 패키지의 전달 큐 청구문을 읽는다.
|
|
5. PostgreSQL 에 알림 플랫폼 마이그레이션을 적용하고 만기 작업을 하나 넣는다.
|
|
6. 자동 커밋 연결 둘로 그 문장을 차례로 실행하고 받은 작업과 행 상태를 비교한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
조정 작업 저장소는 청구 질의를 `FOR UPDATE SKIP LOCKED` 로 끝낸다. javadoc 이 이유를 적어 두었다.
|
|
|
|
```text
|
|
* <p>Claiming is {@code FOR UPDATE SKIP LOCKED} inside the select, for the same reason the delivery
|
|
* queue is: two workers polling the same table must not both take the same job.
|
|
```
|
|
|
|
## 청구 메서드는 트랜잭션을 열지 않는다
|
|
|
|
:::evidence key="a05-f029-for-update-skip-locked" alt="청구 저장소의 javadoc 과 청구 문장과 그것을 실행하는 메서드, 그 클래스의 트랜잭션 경계 선언 수, 같은 패키지 전달 큐의 청구문 전문, 작업자의 처리 순서와 그 클래스의 경계 선언 수, 조정 작업 표의 열 정의와 그 표를 건드리는 마이그레이션 전부, 그리고 알림 플랫폼 마스터 스위치의 출하 기본값과 배경 작업자 스케줄러의 스레드 수를 출력한 터미널 기록." caption="javadoc 은 전달 큐와 같은 이유라고 적음 · 청구는 질의 하나, 경계 선언 0 · 전달 큐는 같은 잠금 구문에 UPDATE 를 붙여 소유자·리스·펜스를 씀 · 표에는 그 열이 없음 · 마스터 스위치 기본값 false, 스케줄러 스레드 1 — 97줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```java
|
|
public List<ReconciliationJob> claimDue(int limit, Instant now) {
|
|
Objects.requireNonNull(now, "now");
|
|
if (limit < 1) {
|
|
throw new IllegalArgumentException("limit");
|
|
}
|
|
return jdbc.query(CLAIM_DUE, MAPPER, Timestamp.from(now), limit);
|
|
}
|
|
```
|
|
|
|
그 클래스에는 `@Transactional` 도 `TransactionPort` 호출도 없다. 자동 커밋에서는 이 SELECT 가 끝나면서 암묵 커밋이 일어나고, 행 잠금은 거기서 사라진다.
|
|
|
|
표에도 남길 곳이 없다. 조정 작업 표에는 소유자도 리스도 상태도 없다. 다음 확인 시각과 시도 횟수와 마지막 결과가 전부다. 이 표를 건드리는 마이그레이션은 만든 것과 색인 하나, 그리고 완결성 검사용 이름 리터럴뿐이다.
|
|
|
|
## 작업자는 조회와 정산 사이에 제공자를 부른다
|
|
|
|
```java
|
|
public int reconcileOnce() {
|
|
List<ReconciliationJob> due = jobs.claimDue(batchSize, clock.instant());
|
|
int settled = 0;
|
|
for (ReconciliationJob job : due) {
|
|
// One job's failure is not the pass's: a provider that is refusing connections would
|
|
// otherwise stop every other provider's jobs behind it.
|
|
try {
|
|
if (handle(job)) {
|
|
settled++;
|
|
}
|
|
} catch (RuntimeException failure) {
|
|
jobs.reschedule(
|
|
job, failure.getClass().getSimpleName(), clock.instant().plus(retryBackoff));
|
|
}
|
|
}
|
|
return settled;
|
|
}
|
|
```
|
|
|
|
목록을 먼저 받고, 반복문 안에서 제공자 조정을 수행한 뒤에야 완료나 재예약을 부른다. 작업자 클래스도 마찬가지로 경계 선언이 없다.
|
|
|
|
## 두 연결이 같은 작업을 받는다
|
|
|
|
:::evidence key="a05-f029-for-update-skip-locked-probe" alt="실제 PostgreSQL 컨테이너에 알림 플랫폼 마이그레이션을 적용해 상위 표들이 세워진 것을 확인하고, 만기 작업 하나를 넣은 뒤 자동 커밋 연결 두 개가 저장소의 청구 문장을 차례로 실행해 각각 받은 작업 식별자와 그 둘이 같은지, 그리고 행 상태를 출력한 터미널 기록. 상위 행 사슬을 만들지 않으려고 외래 키 하나만 뗐다는 단서가 함께 적혀 있다." caption="PostgreSQL 16.15 · 상위 표는 마이그레이션이 세움 · 두 연결 모두 autoCommit true · A 와 B 가 같은 작업을 받음 · 행은 attempts 0, last_result null, 다음 확인 시각 그대로 — 11줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```text
|
|
두 연결의 autoCommit : A=true B=true
|
|
작업자 A 가 받은 작업 : 441a6b30-6574-4af2-a637-1caeef230678
|
|
작업자 B 가 받은 작업 : 441a6b30-6574-4af2-a637-1caeef230678
|
|
같은 작업인가 : true
|
|
행 상태 : attempts=0 last_result=null next_check_at 그대로
|
|
```
|
|
|
|
겹쳐 실행하지 않아도 재현된다. 두 번째 호출 시점에는 첫 호출의 잠금이 이미 사라져 있다.
|
|
|
|
## 전달 청구는 같은 문장 안에서 소유자를 쓴다
|
|
|
|
javadoc 이 이유로 든 전달 큐의 청구문이 같은 패키지에 있다.
|
|
|
|
```sql
|
|
WITH claimable AS (
|
|
SELECT id FROM notification_recipient_delivery
|
|
WHERE next_dispatch_at <= :now
|
|
...
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT :batchSize
|
|
)
|
|
UPDATE notification_recipient_delivery AS d
|
|
SET lease_owner = :owner,
|
|
lease_until = :leaseUntil,
|
|
lease_fence = d.lease_fence + 1,
|
|
delivery_state = 'DISPATCHING',
|
|
...
|
|
```
|
|
|
|
같은 잠금 구문을 CTE 안에 넣고, 그 문장의 `UPDATE` 로 소유자와 리스 만료와 펜스를 쓰고 상태를 배송 중으로 바꾼다. 잠금이 풀리기 전에 청구가 행에 남는다.
|
|
|
|
조정 작업 청구에는 그 `UPDATE` 가 없다. 차이는 절 하나다.
|
|
|
|
## 이 결함이 나타나는 조건
|
|
|
|
알림 플랫폼 마스터 스위치가 켜져 있어야 한다. `application.yml` 이 그것을 거짓으로 내보내므로 기본 배포는 작업자 빈 자체를 만들지 않는다.
|
|
|
|
인스턴스도 둘 이상이어야 한다. 한 프로세스 안에서 이 패스는 스레드 하나짜리 스케줄러에 고정 지연으로 걸려 자기 자신과 겹치지 않는다. 두 작업자란 두 프로세스를 뜻한다.
|
|
|
|
## 영향과 수정
|
|
|
|
조정은 전송이 아니라 제공자 상태 조회와 상태 투영이므로 영향은 전달 경로보다 낮다. 다만 javadoc 이 적은 계약은 깨진다.
|
|
|
|
수정은 전달 청구처럼 잠금과 같은 문장 안에서 소유자와 펜스와 리스를 쓰는 청구문을 두는 것이다. 외부 제공자 호출을 긴 데이터베이스 트랜잭션 안에 넣는 것보다 이 방식이 낫다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
두 작업자가 실제로 제공자 조정을 중복 수행했을 때의 결과를 관측하지 않았다. 확인한 것은 두 호출이 같은 작업을 받는다는 사실이다.
|
|
|
|
탐침은 상위 행 사슬을 만들지 않으려고 외래 키 하나를 뗐다. 표는 마이그레이션이 모두 세운다.
|
|
|
|
<!-- body:end -->
|