From c60112f5f8fccbe75a1eb74ceab49016941c03d0 Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 19 May 2026 10:14:55 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=BC=95=E5=85=A5=20Redis=20Cache-Asid?= =?UTF-8?q?e=20=E6=A8=A1=E5=BC=8F=E4=BC=98=E5=8C=96=20BOM=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=EF=BC=8CTTL=3D12h=EF=BC=8C=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=90=8E=E4=B8=BB=E5=8A=A8=E5=A4=B1=E6=95=88=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inventory-backend/app/api/v1/bom.py | 7 +- inventory-backend/app/services/bom_service.py | 127 +++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index 643381c..6c6ff23 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, jsonify, current_app from sqlalchemy import or_ -from app.services.bom_service import BomService +from app.services.bom_service import BomService, _cache_delete from app.models.base import MaterialBase from app.models.bom import BomTable from app.extensions import db @@ -225,6 +225,11 @@ def delete_bom(bom_no): db.session.delete(rec) db.session.commit() + + # ===== 删除成功后立刻清除缓存(Cache Invalidation) ===== + _cache_delete(bom_no, version) + current_app.logger.info(f"[BOM Cache] delete_bom → 缓存已失效 bom_no={bom_no} version={version}") + return jsonify({ 'code': 200, 'msg': '删除成功', diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index f30485c..8b9593c 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -5,8 +5,96 @@ from app.models.inbound.buy import StockBuy from sqlalchemy import func, distinct, or_, case from collections import defaultdict import uuid +import json +import logging from datetime import datetime +logger = logging.getLogger(__name__) + +# Redis 缓存键前缀 + TTL(秒) +BOM_CACHE_PREFIX = 'bom:tree' +BOM_CACHE_TTL = 43200 # 12小时 + + +def _get_redis(): + """ + 获取 Redis 客户端实例,带容错保护。 + 若 extensions 中没有 redis_client 或连接失败,返回 None。 + 绝不抛出异常,确保业务不因此中断。 + """ + try: + from app.extensions import redis_client + return redis_client + except Exception: + return None + + +def _cache_get(bom_no, version=None): + """ + 从 Redis 读取 BOM 缓存。 + 键 = bom:tree:{bom_no} 或 bom:tree:{bom_no}:{version} + 返回:反序列化后的 dict 或 None + """ + client = _get_redis() + if not client: + return None + + key = f"{BOM_CACHE_PREFIX}:{bom_no}" + (f":{version}" if version else "") + try: + raw = client.get(key) + if raw: + logger.debug(f"[BOM Cache] HIT key={key}") + return json.loads(raw) + logger.debug(f"[BOM Cache] MISS key={key}") + return None + except Exception as e: + logger.warning(f"[BOM Cache] GET 失败,降级查库. key={key}, err={e}") + return None + + +def _cache_set(bom_no, version, data): + """ + 将 BOM 数据写入 Redis,设置 12 小时 TTL。 + 即使写入失败也只是打日志,不阻断业务流程。 + """ + client = _get_redis() + if not client: + return + + key = f"{BOM_CACHE_PREFIX}:{bom_no}" + (f":{version}" if version else "") + try: + client.setex(key, BOM_CACHE_TTL, json.dumps(data, ensure_ascii=False)) + logger.debug(f"[BOM Cache] SET key={key} ttl={BOM_CACHE_TTL}s") + except Exception as e: + logger.warning(f"[BOM Cache] SET 失败,已忽略. key={key}, err={e}") + + +def _cache_delete(bom_no, version=None): + """ + 删除 Redis 中指定 BOM 的缓存条目。 + 在写操作(增/改/删)成功后调用,确保后续读请求拿到最新数据。 + """ + client = _get_redis() + if not client: + return + + # 删除版本级缓存 + if version: + key = f"{BOM_CACHE_PREFIX}:{bom_no}:{version}" + try: + client.delete(key) + logger.debug(f"[BOM Cache] DEL key={key}") + except Exception as e: + logger.warning(f"[BOM Cache] DEL 失败,已忽略. key={key}, err={e}") + + # 同时删除"最新版"缓存(不带版本后缀),避免缓存不一致 + key_latest = f"{BOM_CACHE_PREFIX}:{bom_no}" + try: + client.delete(key_latest) + logger.debug(f"[BOM Cache] DEL key={key_latest}") + except Exception as e: + logger.warning(f"[BOM Cache] DEL 失败,已忽略. key={key_latest}, err={e}") + class BomService: # ====================== 新版 BOM 逻辑(基于 bom_no) ====================== @@ -121,8 +209,25 @@ class BomService: @staticmethod def get_bom_detail(bom_no, version=None): """ - 根据 bom_no (和 version) 获取配方详情 + 根据 bom_no (和 version) 获取配方详情。 + + Cache-Aside 模式(三步走): + 1. 先查 Redis,有值直接返回(Cache Hit) + 2. 无值或 Redis 报错,查数据库(Cache Miss → Fallback) + 3. 数据库查好后写入 Redis,TTL=12h,供下次命中 + + 注意:查询"最新版"时(version=None),缓存键不带版本后缀; + 写入时也写入不带版本的键,这样无需指定 version 就能命中。 """ + # ===== 第一步:尝试从 Redis 读取缓存 ===== + cached = _cache_get(bom_no, version if version else None) + if cached is not None: + # Cache Hit:直接返回缓存数据,不再查库 + logger.debug(f"[BOM] get_bom_detail bom_no={bom_no} version={version} → 命中缓存") + return cached + + # ===== 第二步:Cache Miss → 查数据库 ===== + logger.debug(f"[BOM] get_bom_detail bom_no={bom_no} version={version} → 查询数据库") query = db.session.query( BomTable, MaterialBase.name.label('child_name'), @@ -141,6 +246,8 @@ class BomService: if not latest_ver: return None query = query.filter(BomTable.version == latest_ver) + # 记录本次实际查的版本,用于缓存键 + version = latest_ver rows = query.all() if not rows: @@ -160,7 +267,7 @@ class BomService: 'remark': bom.remark or '' }) - return { + result = { 'bom_no': bom_no, 'version': first.BomTable.version, 'parent_id': parent_id, @@ -170,6 +277,11 @@ class BomService: 'children': children } + # ===== 第三步:写入 Redis 缓存(TTL=12h),失败只打日志不阻断 ===== + _cache_set(bom_no, version, result) + + return result + @staticmethod def save_bom(data): """保存 BOM (支持多版本),新增跨版本内容查重""" @@ -239,6 +351,12 @@ class BomService: db.session.add(bom) db.session.commit() + + # ===== 写入后立刻清除缓存(Cache Invalidation) ===== + # 确保后续 get_bom_detail 读取到最新数据,而不是 stale cache + _cache_delete(bom_no, version) + logger.info(f"[BOM Cache] save_bom → 缓存已失效 bom_no={bom_no} version={version}") + return bom_no @staticmethod @@ -312,6 +430,11 @@ class BomService: ) db.session.add(bom) db.session.commit() + + # ===== 写入后立刻清除缓存(Cache Invalidation) ===== + _cache_delete(bom_no, version) + logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}") + return True @staticmethod