fix: L3 乐观锁防丢更新/last_alert_id 保留语义/竞态日志(B3 评审 P1-1、P2-1~5)

This commit is contained in:
2026-09-06 16:42:18 +08:00
parent 1f4217899d
commit 026c4ce75c
4 changed files with 135 additions and 75 deletions
+23 -19
View File
@@ -310,29 +310,33 @@ class RiskRepository:
monitor_tags: list[str],
last_alert_id: str | None,
computed_at: datetime,
) -> None:
"""整行更新(service 层完成最高档/tags 合并后调用;computed_at NOT NULL 必传)。"""
sql = text(
"""
expected_computed_at: datetime | None = None,
) -> bool:
"""整行更新(service 层完成最高档/tags 合并后调用;computed_at NOT NULL 必传)。
expected_computed_at 传入时为乐观锁(比对读时的 computed_at),行已被并发
修改则不命中,返回 False 由 service 层重读重试(B3 评审 P1-1 防丢更新)。
"""
sql = """
UPDATE customer_profile_l3
SET monitor_tier = :tier, risk_score = :score, score_dimensions = :dims,
monitor_tags = :tags, last_alert_id = :last_alert, computed_at = :computed_at
WHERE customer_id = :cid
"""
)
WHERE customer_id = :cid"""
params: dict[str, Any] = {
"cid": customer_id,
"tier": monitor_tier,
"score": risk_score,
"dims": json.dumps(score_dimensions or {}, ensure_ascii=False),
"tags": json.dumps(monitor_tags, ensure_ascii=False),
"last_alert": last_alert_id,
"computed_at": computed_at,
}
if expected_computed_at is not None:
sql += " AND computed_at = :expected"
params["expected"] = expected_computed_at
with self._engine.begin() as conn:
conn.execute(
sql,
{
"cid": customer_id,
"tier": monitor_tier,
"score": risk_score,
"dims": json.dumps(score_dimensions or {}, ensure_ascii=False),
"tags": json.dumps(monitor_tags, ensure_ascii=False),
"last_alert": last_alert_id,
"computed_at": computed_at,
},
)
res = conn.execute(text(sql), params)
return res.rowcount == 1
# ---------- risk_aml_list ----------
+40 -52
View File
@@ -1,11 +1,15 @@
"""L3 监测画像写入(B3 · PRD FR-7 / R-05 最小写入)。
合并规则(防降级,PRD FR-7):monitor_tier 取最高档(normal < watch < high),
monitor_tags 追加合并不覆盖,risk_score 取 max,computed_at 每次写当前时间
(列 NOT NULL 必须显式赋值),last_alert_id 联动最新预警。
monitor_tags 追加合并不覆盖,risk_score 取 max(FR-7 未定义的口径外推,见
RISK-00n 静态分;R-05 动态评分接入时复核),last_alert_id 传入时联动最新预警
(未传保留旧值,防调用方漏传抹掉),computed_at 每次写当前时间(列 NOT NULL,
毫秒截断对齐 DATETIME(3),保证乐观锁读写比对一致)。
并发:进程内锁按 customer_id 串行(多进程部署换 Redis SET NX,接口不变);
锁超时降级与跨进程竞态由 IntegrityError 兜底——重读合并后再 update,不丢更新。
并发(B3 评审 P1-1 修复):进程内锁按 customer_id 串行减少冲突(多进程部署换
Redis SET NX,接口不变);锁超时降级与跨进程竞态由乐观锁兜底——update 比对
读时 computed_at,未命中或 insert 撞主键则重读合并重试,3 次仍冲突抛错
(风控数据宁失败不静默覆盖)。
"""
from __future__ import annotations
@@ -30,6 +34,12 @@ ALERT_TYPE_TIER = {
"suitability": "normal",
}
AML_PENDING_TAG = "aml_hit_pending_review"
_MAX_RETRIES = 3
def _ms(dt: datetime) -> datetime:
"""毫秒截断(对齐表列 DATETIME(3);否则乐观锁 expected 比对永不命中)。"""
return dt.replace(microsecond=(dt.microsecond // 1000) * 1000)
def tier_of(alert_type: str) -> str:
@@ -60,12 +70,13 @@ def merge_l3(
existing 为 None 表示新客户首写;risk_score 双方均空时保持 NULL。
"""
if existing is None:
tier, old_score, old_tags, old_dims = "normal", None, [], {}
tier, old_score, old_tags, old_dims, old_alert = "normal", None, [], {}, None
else:
tier = existing.get("monitor_tier") or "normal"
old_score = existing.get("risk_score")
old_tags = existing.get("monitor_tags") or []
old_dims = existing.get("score_dimensions") or {}
old_alert = existing.get("last_alert_id")
scores = [int(s) for s in (old_score, risk_score) if s is not None]
return {
@@ -73,8 +84,8 @@ def merge_l3(
"risk_score": max(scores) if scores else None,
"monitor_tags": sorted(set(old_tags) | set(monitor_tags)),
"score_dimensions": score_dimensions if score_dimensions is not None else old_dims,
"last_alert_id": last_alert_id,
"computed_at": computed_at or datetime.now(),
"last_alert_id": last_alert_id if last_alert_id is not None else old_alert,
"computed_at": _ms(computed_at or datetime.now()),
}
@@ -88,9 +99,10 @@ def upsert_profile_l3(
risk_repo: RiskRepository | None = None,
computed_at: datetime | None = None,
) -> dict[str, Any]:
"""预警事件 → L3 upsert(B2 预警落库后调用;aml 自动追加待复核标签)。
"""预警事件 → L3 upsert(aml 自动追加待复核标签)。
返回合并后的 L3 行(含 customer_id)。
返回合并后的 L3 行(含 customer_id)。B7 接 Redis 缓存时须在此处加
写侧 DEL 钩子(PRD §5.1:MySQL 更新时 DEL profile:l3:{customer_id})。
"""
repo = risk_repo or RiskRepository()
mapped_tier = tier_of(alert_type)
@@ -98,45 +110,11 @@ def upsert_profile_l3(
if alert_type == "aml" and AML_PENDING_TAG not in tags:
tags.append(AML_PENDING_TAG)
def _write(locked: bool) -> dict[str, Any]:
existing = repo.get_l3(customer_id)
merged = merge_l3(
existing,
mapped_tier=mapped_tier,
risk_score=risk_score,
monitor_tags=tags,
last_alert_id=last_alert_id,
score_dimensions=score_dimensions,
computed_at=computed_at,
)
try:
if existing is None:
repo.insert_l3(
customer_id,
merged["monitor_tier"],
merged["risk_score"],
merged["score_dimensions"],
merged["monitor_tags"],
merged["last_alert_id"],
merged["computed_at"],
)
else:
repo.update_l3(
customer_id,
merged["monitor_tier"],
merged["risk_score"],
merged["score_dimensions"],
merged["monitor_tags"],
merged["last_alert_id"],
merged["computed_at"],
)
except IntegrityError:
# 首写竞态(锁超时降级/跨进程):另一线程已 insert,重读合并转更新
if existing is not None:
raise
raced = repo.get_l3(customer_id)
def _write(locked: bool) -> dict[str, Any]: # 锁原语签名要求(是否获得锁);冲突安全由乐观锁重试保证
for attempt in range(1, _MAX_RETRIES + 1):
existing = repo.get_l3(customer_id)
merged = merge_l3(
raced,
existing,
mapped_tier=mapped_tier,
risk_score=risk_score,
monitor_tags=tags,
@@ -144,8 +122,7 @@ def upsert_profile_l3(
score_dimensions=score_dimensions,
computed_at=computed_at,
)
repo.update_l3(
customer_id,
fields = (
merged["monitor_tier"],
merged["risk_score"],
merged["score_dimensions"],
@@ -153,12 +130,23 @@ def upsert_profile_l3(
merged["last_alert_id"],
merged["computed_at"],
)
merged["customer_id"] = customer_id
return merged
if existing is None:
try:
repo.insert_l3(customer_id, *fields)
merged["customer_id"] = customer_id
return merged
except IntegrityError:
logger.warning("L3 insert race, retrying (attempt %d): %s", attempt, customer_id)
continue
if repo.update_l3(customer_id, *fields, expected_computed_at=existing["computed_at"]):
merged["customer_id"] = customer_id
return merged
logger.warning("L3 update lost race, retrying (attempt %d): %s", attempt, customer_id)
raise RuntimeError(f"L3 upsert conflicted after {_MAX_RETRIES} retries: {customer_id}")
return _run_locked(f"l3:{customer_id}", _write)
def get_profile_l3(customer_id: str, risk_repo: RiskRepository | None = None) -> dict[str, Any] | None:
"""L3 只读薄封装(对话线/引擎复用;Redis 缓存待 B7 lifespan 一并接入)。"""
"""L3 只读薄封装(对话线/引擎复用;Redis 热缓存与写侧 DEL 钩子待 B7 lifespan 一并接入)。"""
return (risk_repo or RiskRepository()).get_l3(customer_id)