引子
今天的工作涵盖了一层完整的开发横切面:从线上事故排查、第三方服务对接踩坑、到四个中等复杂度的功能模块并行交付。整个过程踩了不少细节,记下来备忘,也供有类似场景的同学参考。
Part 1:线上事故排查 — COS AccessDenied 导致服务崩溃
现象
云托管日志中频繁出现以下堆栈尾部:
File "/usr/local/lib/python3.12/site-packages/starlette/_utils.py", line 82, in collapse_excgroups
self.gen.throw(value)
日志不完整,没有显式报错信息,但服务反复重启。
定位过程
堆栈倒推:collapse_excgroups → 中间件层异常渗出 → 请求处理中发生未捕获异常。
检查 auth.py 的 upload-avatar 端点:
python
复制
# 修复前
client.put_object(Bucket=settings.COS_BUCKET, Body=content, Key=key)
# ↑ COS SDK 抛出 CosServiceError 没有被捕获,直接穿透所有中间件
对比 auth_service.py 中登录流程的同一段逻辑——它是有 try/except 的。问题根源是一个不对称防御漏洞:同类型的 COS 操作在不同代码路径中存在差异化的异常处理。
修复
在所有 COS SDK 直接调用处统一包裹异常保护:
python
复制
# auth.py / upload-avatar
try:
content = base64.b64decode(b64)
client.put_object(...)
except Exception as e:
logger.warning(f"头像上传 COS 失败: {e}")
return R(code=500, message="头像上传失败,请稍后重试")
同时修复 cos_client.py 中的 upload_to_cos 工具函数,两处加 try/except 形成纵深防御。
后续踩坑:Bucket Policy vs CAM 权限
异常保护只是"不崩溃",但 COS 仍然返回 AccessDenied。排查发现两个独立问题:
-
环境变量填错桶名:变量
COS_BUCKET指向云托管自动生成的桶7072-prod-...,但 Bucket Policy 配在了手动创建的group-points-1333612437上——完全两个桶。 -
Region 不匹配:变量填了
ap-shanghai,但目标桶在广州(ap-guangzhou)。COS 的 Region 不对会导致请求路由到错误节点,直接拒绝。
更正环境变量后上传恢复正常。
教训:COS 鉴权是两层——Bucket Policy(门禁规则)+ CAM 子账号权限(门禁卡)。主账号虽然天然有全量权限,但桶名和 Region 任何一个不对都会 AccessDenied。
Part 2:wx.showLoading 配对警告
现象
微信开发者工具控制台反复输出:
请注意 showLoading 与 hideLoading 必须配对使用
调用栈指向 edit.js 的 onChooseAvatar 方法。
根因
wx.showLoading 内部是异步执行的。原来的 async/await 写法中,wx.showLoading() 和 wx.hideLoading() 之间存在不可控的时序间隙——网络请求可能极快返回,hideLoading 在 showLoading 真正生效之前就到了。
另一个潜在问题是用户快速连点,触发多次 onChooseAvatar,导致多个 showLoading 同时存在。
最终方案
放弃 showLoading/hideLoading 对,改用 wx.showToast({ icon: 'loading' }) + wx.hideToast(),这两个不需要配对约束。同时加 _uploading 互斥锁防重复触发。
javascript
复制
onChooseAvatar(e) {
if (this._uploading) return;
this._uploading = true;
wx.showToast({ title: '上传中...', icon: 'loading', duration: 10000 });
try {
// ... upload logic ...
wx.hideToast();
} catch (err) {
wx.hideToast();
} finally {
this._uploading = false;
}
}
Part 3:Redis 缓存落地
设计思路
项目最初在 requirements.txt 中已引入 redis==5.2.1,但一直没写连接管理和缓存层。现在补上,设计原则:
- 降级友好:Redis 未连接时缓存操作静默跳过,不阻塞业务
- 薄封装:只用
get/setex/delete/scan,不依赖重型缓存框架 - Cache-aside 模式:业务代码显式控制缓存读写和失效
核心实现
连接管理 (core/redis.py):
python
复制
import redis.asyncio as aioredis
_redis_pool: aioredis.Redis | None = None
async def get_redis() -> aioredis.Redis:
global _redis_pool
if _redis_pool is None:
_redis_pool = await aioredis.from_url(
url, encoding="utf-8", decode_responses=True, max_connections=20
)
await _redis_pool.ping()
return _redis_pool
缓存工具层:cache_get / cache_set / cache_delete / cache_delete_pattern,全部内置 try/except 异常静默。
装饰器 @cached(ttl=300):自动缓存异步函数返回值,key 指纹 = 函数名 + 参数值。智能跳过 SQLAlchemy session 对象(避免把 db session 序列化进 key)。
python
复制
@cached(ttl=60, prefix="project")
async def get_project_detail(db, project_id):
...
业务接入
首期接入 项目列表查询:
- 查询时:先查缓存 → 命中直接返回 → 未命中查 DB 并回写
- 写入时:创建项目、成员变更时主动
cache_delete失效 - TTL:120 秒(2 分钟),兼顾实时性和 DB 保护
python
复制
# project_service.py 中的缓存逻辑
cache_key = f"projects:my:{user_id}"
cached = await cache_get(cache_key)
if cached:
return [Project(**p) for p in cached]
# ... DB 查询 ...
await cache_set(cache_key, cache_data, ttl=120)
应用启动(main.py lifespan)中初始化 Redis 连接,失败时只打 warning 不阻断启动。
Part 4:项目自动状态变更
需求
项目设有 deadline(截止日期),到期后应自动从 active 变为 ended。这在传统后端中通常靠 Celery Beat / APScheduler 实现,但我们项目不想引入额外组件。
方案:asyncio 原生后台任务
FastAPI 的 lifespan 上下文管理器可以启动 asyncio task,完美替代 cron:
python
复制
async def _scheduler_loop():
while True:
await asyncio.sleep(300) # 5 分钟
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
update(Project)
.where(
Project.status == "active",
Project.deadline.isnot(None),
Project.deadline < func.now(),
)
.values(status="ended")
)
await db.commit()
if result.rowcount:
logger.info(f"{result.rowcount} 个项目已自动结束")
except Exception as e:
logger.warning(f"定时任务异常: {e}")
在 lifespan 中启动并在 shutdown 时 cancel:
python
复制
@asynccontextmanager
async def lifespan(app):
# ... init ...
scheduler_task = asyncio.create_task(_scheduler_loop())
yield
scheduler_task.cancel()
await scheduler_task # 优雅等待取消
优点:零外部依赖、和 FastAPI 同进程、自动随服务启停。适用条件:单实例部署(云托管确实只有一个实例)。
Part 5:操作动态流
设计
新增 activities 表作为事件溯源:
python
复制
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True)
project_id = Column(Integer, ForeignKey("projects.id"), index=True)
user_id = Column(Integer, ForeignKey("users.id"))
action_type = Column(String(32)) # create_project / create_task / claim_task / submit_review / review_approve / member_join
target_type = Column(String(32)) # project / task / member
target_id = Column(Integer)
description = Column(String(512)) # 人类可读的描述文本
meta_json = Column(Text) # 结构化扩展数据 (JSON)
created_at = Column(DateTime, default=func.now(), index=True)
# 复合索引:按项目+时间查动态流
__table_args__ = (
Index("ix_activities_project_time", "project_id", "created_at"),
)
关键设计决策
flush 而非 commit:create_activity 只做 db.flush(),把 commit 留给外层路由的事务边界。这样如果业务操作失败回滚,动态记录也一并回滚,保证数据一致性。
静态 description:动态文本直接存为字符串(如 "📋 发布了新任务「设计评审」"),不依赖 JOIN 查询。这避免了"用户改名后历史动态文字变化"的问题——和审计日志一样,记录的是当时的快照。
埋点位置
在以下业务节点植入 create_activity 调用:
节点action_type触发时机创建项目create_projectproject_service.create_project发布任务create_tasktask_service.create_task接取任务claim_tasktask_service.claim_task提交审核submit_reviewtask_service.submit_review / review_service审核通过review_approvereview_service.process_review审核驳回review_rejectreview_service.process_review成员加入member_joinproject_service.review_join_request
API & 前端
- API:
GET /api/v1/projects/{id}/activities?page=1&page_size=30,分页返回时间倒序的动态列表 - 前端:时间线样式卡片,左竖线 + 彩色圆点 + 描述文本 + 时间戳,下拉刷新 + 触底加载更多
- 入口:项目详情页 Tab 栏新增"动态"按钮
Part 6:Canvas 图表性能优化
问题
原有的 Canvas 2D 图表实现了基本功能,但存在几个问题:
- 无条件重绘:每次
setData回调都重新绘制,即使数据没变 - 无动画:折线图和饼图直接全量渲染,视觉上太生硬
- 未清理定时器:页面切换后动画 timer 继续跑,浪费资源
优化方案
1. 数据指纹缓存
javascript
复制
drawTrendChart() {
const fingerprint = JSON.stringify(data);
if (this._lastRendered === fingerprint) return;
this._lastRendered = fingerprint;
// ... 继续渲染
}
用 JSON 序列化作为数据指纹,相同数据直接跳过。简单但有效——对于图表场景,数据不变化时没必要重绘。
2. 帧动画
折线图用 30 帧 ease-out 入场:
javascript
复制
const totalFrames = 30;
let frame = 0;
const animate = () => {
const progress = Math.min(frame / totalFrames, 1);
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
const visibleCount = Math.ceil(points.length * eased);
// 只绘制已出现的线段和填充区域
// ...
frame++;
if (frame <= totalFrames) {
this._animTimer = setTimeout(animate, 16); // 60fps
}
};
饼图同理,扇形角度从 0 逐渐展开到完整角度(20 帧 + ease-out quadratic)。
3. 生命周期管理
onHide 中取消动画:
javascript
复制
onHide() {
if (this._animTimer) clearTimeout(this._animTimer);
this._animTimer = null;
}
防止页面切走后后台还在跑渲染。
4. 渲染路径优化
- 折线区域 + 数据点在一次 path 中完成,减少 GPU 状态切换
- 渐变填充用
createLinearGradient预计算 - 避免不必要的
save/restore栈操作 - 标签线在动画完成度 > 80% 时才绘制,减少闪烁
工程体会
-
不对称防御是 Bug 的温床:同一操作在不同代码路径有不同异常处理 → 必然漏网。统一工具函数 + 统一异常策略才是正解。
-
第三方鉴权排查要有系统思维:COS AccessDenied 可能的原因不止一种——桶名、Region、Bucket Policy、CAM 权限、密钥错误都需要排查,不要停在第一个假设上。
-
asyncio 原生的力量被低估了:很多人一说到定时任务就上 Celery,但在单实例场景下,
asyncio.create_task+while sleep足矣,省掉整个组件的运维成本。 -
缓存层要"温顺":每个操作都
try/except,Redis 挂了自动降级,不做强依赖。这是所有缓存方案的黄金法则。 -
Canvas 动画不是花活:在小程序这种单线程环境,入场动画能让用户感知"数据在加载"而不是"卡住了",有一定实际价值。但必须做好定时器清理,否则是定时炸弹。