幂等性学习笔记

📘 WPF 幂等性学习笔记

核心一句话:同一个操作,执行一次和执行 N 次,对系统产生的最终影响必须完全一样。


一、概念篇

1.1 定义

幂等(Idempotent)源自数学:f(f(x)) = f(x),即函数重复作用于自身结果不变。

在软件工程中:同一操作被执行一次与被执行多次,系统的最终状态完全相同。

1.2 两个视角

视角 说明
数学 f(f(x)) = f(x),重复应用结果不变
HTTP GET / PUT / DELETE 天然幂等;POST 不幂等(重复提交会创建多份资源)

1.3 生活类比

例子 是否幂等 原因
电梯"关门"按钮 ✅ 按 1 次和按 10 次,门只关一次
手机音量设为 50% ✅ 设多少次都是 50%(Set 语义)
遥控器"开/关"键 ❌ 按两次灯又灭了(Toggle 语义)
银行转账扣款 100 元 ❌ 重复执行会扣多次,必须靠机制保证

1.4 天然幂等 vs 非幂等操作

操作类型 示例 是否幂等
查询 Read SELECT / 读取属性 ✅
删除 Delete 删除 id=5 的记录(再删结果一样) ✅
整体赋值 Set x = 5 / UPDATE name='a' ✅
自增 Increment x = x + 1 ❌
追加 Append 日志追加 / List.Add ❌
创建 Create INSERT 新订单 ❌(需幂等键保护)

1.5 WPF 客户端为什么需要幂等

  • 用户手抖双击按钮 → 重复提交订单
  • 网络超时重试 → 重复下单
  • 事件被多次订阅 → 处理器执行 N 次
  • 窗口被重复打开 → 出现多个实例
  • 定时器 / 异步回调重入 → 状态错乱
  • 多端并发保存 → 数据互相覆盖

二、WPF 经典示例篇

示例 1:按钮防重复提交(最经典)⭐

场景:点击"提交订单",请求耗时 2 秒,用户等不及连点 5 下 → 生成 5 笔订单。

csharp 复制代码
// ❌ 非幂等:双击 = 提交两次
private async void Submit_Click(object sender, RoutedEventArgs e)
{
    await _orderService.CreateOrderAsync();
}
csharp 复制代码
// ✅ 幂等:状态守卫 + 按钮禁用,重复点击只生效一次
private bool _isSubmitting;

private async void Submit_Click(object sender, RoutedEventArgs e)
{
    if (_isSubmitting) return;      // 幂等守卫:进行中直接忽略
    _isSubmitting = true;
    BtnSubmit.IsEnabled = false;    // UI 层再兜底一层
    try
    {
        await _orderService.CreateOrderAsync();
    }
    finally
    {
        _isSubmitting = false;
        BtnSubmit.IsEnabled = true;
    }
}

流程图:
#mermaid-svg-zoqn890sd2CEywjN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zoqn890sd2CEywjN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zoqn890sd2CEywjN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zoqn890sd2CEywjN .error-icon{fill:#552222;}#mermaid-svg-zoqn890sd2CEywjN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zoqn890sd2CEywjN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zoqn890sd2CEywjN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zoqn890sd2CEywjN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zoqn890sd2CEywjN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zoqn890sd2CEywjN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zoqn890sd2CEywjN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zoqn890sd2CEywjN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zoqn890sd2CEywjN .marker.cross{stroke:#333333;}#mermaid-svg-zoqn890sd2CEywjN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zoqn890sd2CEywjN p{margin:0;}#mermaid-svg-zoqn890sd2CEywjN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zoqn890sd2CEywjN .cluster-label text{fill:#333;}#mermaid-svg-zoqn890sd2CEywjN .cluster-label span{color:#333;}#mermaid-svg-zoqn890sd2CEywjN .cluster-label span p{background-color:transparent;}#mermaid-svg-zoqn890sd2CEywjN .label text,#mermaid-svg-zoqn890sd2CEywjN span{fill:#333;color:#333;}#mermaid-svg-zoqn890sd2CEywjN .node rect,#mermaid-svg-zoqn890sd2CEywjN .node circle,#mermaid-svg-zoqn890sd2CEywjN .node ellipse,#mermaid-svg-zoqn890sd2CEywjN .node polygon,#mermaid-svg-zoqn890sd2CEywjN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zoqn890sd2CEywjN .rough-node .label text,#mermaid-svg-zoqn890sd2CEywjN .node .label text,#mermaid-svg-zoqn890sd2CEywjN .image-shape .label,#mermaid-svg-zoqn890sd2CEywjN .icon-shape .label{text-anchor:middle;}#mermaid-svg-zoqn890sd2CEywjN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zoqn890sd2CEywjN .rough-node .label,#mermaid-svg-zoqn890sd2CEywjN .node .label,#mermaid-svg-zoqn890sd2CEywjN .image-shape .label,#mermaid-svg-zoqn890sd2CEywjN .icon-shape .label{text-align:center;}#mermaid-svg-zoqn890sd2CEywjN .node.clickable{cursor:pointer;}#mermaid-svg-zoqn890sd2CEywjN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zoqn890sd2CEywjN .arrowheadPath{fill:#333333;}#mermaid-svg-zoqn890sd2CEywjN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zoqn890sd2CEywjN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zoqn890sd2CEywjN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zoqn890sd2CEywjN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zoqn890sd2CEywjN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zoqn890sd2CEywjN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zoqn890sd2CEywjN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zoqn890sd2CEywjN .cluster text{fill:#333;}#mermaid-svg-zoqn890sd2CEywjN .cluster span{color:#333;}#mermaid-svg-zoqn890sd2CEywjN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zoqn890sd2CEywjN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zoqn890sd2CEywjN rect.text{fill:none;stroke-width:0;}#mermaid-svg-zoqn890sd2CEywjN .icon-shape,#mermaid-svg-zoqn890sd2CEywjN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zoqn890sd2CEywjN .icon-shape p,#mermaid-svg-zoqn890sd2CEywjN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zoqn890sd2CEywjN .icon-shape rect,#mermaid-svg-zoqn890sd2CEywjN .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zoqn890sd2CEywjN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zoqn890sd2CEywjN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zoqn890sd2CEywjN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
用户点击提交按钮
isSubmitting 为 true?
忽略本次点击
置标志为 true 并禁用按钮
发送创建订单请求
请求完成
恢复按钮并复位标志

要点 :finally 保证异常时也能复位;逻辑守卫是主防线,禁用按钮只是 UI 兜底。


示例 2:ICommand.CanExecute ------ 框架级幂等防线

场景 :MVVM 模式下,WPF 命令系统在 CanExecute 返回 false 时自动禁用按钮,命令根本不会被触发。

csharp 复制代码
public class AsyncRelayCommand : ICommand
{
    private bool _isExecuting;

    public bool CanExecute(object parameter)
        => !_isExecuting;               // 执行中 → 按钮自动禁用 → 重复触发无效

    public async void Execute(object parameter)
    {
        _isExecuting = true;
        RaiseCanExecuteChanged();       // 通知 UI 刷新按钮状态
        try { await ExecuteAsync(); }
        finally { _isExecuting = false; RaiseCanExecuteChanged(); }
    }
}

要点:把幂等守卫封装进 Command 基类,所有 ViewModel 复用,一劳永逸。


示例 3:窗口单例 ------ 防止重复打开窗口

场景:连点"打开设置"按钮,弹出 5 个设置窗口。

csharp 复制代码
// ❌ 非幂等:点几次开几个
private void OpenSettings_Click(object sender, RoutedEventArgs e)
    => new SettingsWindow().Show();
csharp 复制代码
// ✅ 幂等:无论点多少次,永远只有一个设置窗口
private void OpenSettings_Click(object sender, RoutedEventArgs e)
{
    var existing = Application.Current.Windows
                        .OfType<SettingsWindow>()
                        .FirstOrDefault();
    if (existing != null)
    {
        existing.Activate();    // 已存在 → 激活置顶即可
        return;
    }
    new SettingsWindow().Show();
}

要点 :Show() 换成 ShowDialog() 时更要注意,模态窗口重复打开会锁死 UI 线程。


示例 4:事件订阅"先减后加" ⭐

场景 :页面每次加载都 += 一次,返回再进入后事件触发时处理器执行 2 次、3 次......还会导致内存泄漏。

csharp 复制代码
// ❌ 非幂等:重复订阅
protected override void OnLoaded(RoutedEventArgs e)
{
    _service.DataChanged += OnDataChanged;   // 每次加载都叠加一份
}
csharp 复制代码
// ✅ 幂等:先减后加,订阅多少次都只注册一份
protected override void OnLoaded(RoutedEventArgs e)
{
    _service.DataChanged -= OnDataChanged;   // 先解除
    _service.DataChanged += OnDataChanged;   // 再订阅
}

要点 :CommunityToolkit.Mvvm 的 Messenger 同理 ------ 先 Unregister 再 Register。


示例 5:属性 Setter ------ 值不变则跳过

场景 :给 Title 赋相同值,PropertyChanged 被反复触发,绑定 UI 无效刷新。

csharp 复制代码
// ✅ 幂等:值没变就不做任何事
private string _title;
public string Title
{
    get => _title;
    set
    {
        if (_title == value) return;    // 幂等守卫
        _title = value;
        OnPropertyChanged(nameof(Title));
    }
}

要点 :CommunityToolkit.Mvvm 生成的 SetProperty 已内置此判断 ------ 这就是幂等思想的框架化。


示例 6:初始化只执行一次

场景:多个页面都调用"确保数据库已初始化",初始化逻辑被跑多次。

csharp 复制代码
// ✅ Lazy<Task<T>>:无论调用多少次,初始化只真正执行一次
private static readonly Lazy<Task<DatabaseService>> _instance =
    new(() => DatabaseService.InitAsync());

public static Task<DatabaseService> GetServiceAsync() => _instance.Value;

要点 :Lazy<T> 默认线程安全;缓存的是 Task 而不是结果,避免并发时重复初始化。


示例 7:集合刷新 ------ 整体覆盖代替增量追加

场景 :点"刷新"按钮重新拉取列表,若用 Add 追加,数据越刷越多。

csharp 复制代码
// ✅ 幂等:Clear + Add = Set 语义,刷新 100 次结果一致
public async Task RefreshAsync()
{
    var data = await _api.GetItemsAsync();
    Items.Clear();
    foreach (var item in data)
        Items.Add(item);
}

要点:刷新类操作一律用"覆盖"语义;数据量大时可整体替换集合引用以减少 UI 闪烁。


示例 8:定时器 / 异步回调重入保护

场景 :DispatcherTimer 每 1 秒触发一次,但工作耗时 3 秒 → 多个工作同时跑,状态错乱。

csharp 复制代码
private int _isRunning;

private async void OnTimerTick(object sender, EventArgs e)
{
    // 原子操作:0 换 1 成功才继续,防止重入
    if (Interlocked.CompareExchange(ref _isRunning, 1, 0) != 0) return;
    try
    {
        await DoWorkAsync();
    }
    finally
    {
        Interlocked.Exchange(ref _isRunning, 0);
    }
}

要点 :bool 判断在多线程下不可靠,必须用 Interlocked 原子操作。


示例 9:本地数据库 Upsert ------ 唯一键覆盖写

场景:WPF 客户端同步服务端数据到本地 SQLite,网络重试导致重复插入。

sql 复制代码
-- ✅ 幂等:无论同步多少次,本地只有一条 id=1001 的记录
INSERT OR REPLACE INTO Orders (Id, Name, Amount)
VALUES (1001, '订单A', 99);

要点 :靠唯一业务键 + 覆盖写实现幂等,而不是靠"祈祷不重复"。


示例 10:幂等键 + 乐观锁(与服务端配合)⭐

场景 A:创建订单超时,不知道成没成功,重试怕重复下单。

csharp 复制代码
// ✅ 幂等键:草稿单号在提交前生成,重试时复用同一个 key
public async Task SubmitWithRetryAsync(OrderDraft draft)
{
    var idempotencyKey = draft.DraftId.ToString();   // 关键:重试不换 key
    for (int attempt = 0; attempt < 3; attempt++)
    {
        try
        {
            await _api.CreateOrderAsync(draft, idempotencyKey);
            return;                                  // 服务端按 key 去重
        }
        catch (TimeoutException) { /* 用同一个 key 继续重试 */ }
    }
}

场景 B:多人同时编辑同一条数据,后保存的覆盖先保存的。

csharp 复制代码
// ✅ 乐观锁:保存时带上读取时的版本号
await _api.SaveAsync(order.Id, order, version: order.Version);
// 服务端:UPDATE ... WHERE Id=@id AND Version=@version
// 影响行数为 0 → 数据已被别人改过 → 提示冲突重新加载

三、幂等设计套路速查表

套路 做法 WPF 对应场景
状态守卫 if (isRunning) return; 防重复点击、防重入
框架防线 CanExecute 返回 false ICommand 按钮禁用
单例 / Lazy 实例只创建一次 窗口单例、服务初始化
先减后加 -= 再 += 事件 / Messenger 订阅
值不变则跳过 if (field == value) return; 属性 Setter 通知
整体覆盖 Set Clear + Add / 替换引用 集合刷新
原子交换 Interlocked.CompareExchange 定时器 / 多线程重入
唯一键 Upsert INSERT OR REPLACE 本地 SQLite 缓存同步
幂等键 业务唯一 ID 随请求发送 调接口防重复下单
乐观锁 version 校验 并发保存冲突检测

四、常见误区

  1. 幂等 ≠ 只执行一次:幂等是"执行多次效果同一次",可以"重复执行 + 去重",也可以"直接拒绝重复执行",两条路都算幂等。
  2. 禁用按钮不是真正的幂等:绕过 UI(快捷键、代码直接调命令)仍可能重复,逻辑层守卫才是主防线。
  3. += 订阅忘记 -=:是 WPF 内存泄漏 + 处理器重复执行的双重根源。
  4. async void 事件处理器无法被等待:重入风险最高,务必加守卫标志。
  5. bool 标志在多线程下不可靠 :跨线程场景用 Interlocked 或 SemaphoreSlim(1,1)。

相关推荐
Xudde.2 小时前
HackMe 靶机渗透测试(Vulnhub Writeup)
笔记·学习·安全·web安全
先途检测认证2 小时前
塑料、硅胶、不锈钢材质,欧盟食品接触的测试项目有什么区别?
笔记·材质
跨知易知识产权2 小时前
美国发明专利和外观专利保护期限分别是多久,起算时间怎么界定?
笔记
wdfk_prog2 小时前
LWIP教程 16:从 MTU 到 `ip4_frag()` / `ip4_reass()`——IPv4 分片、重组、超时与内存压力
运维·服务器·网络·笔记·学习
Source.Liu3 小时前
【A11】拥有者模式平台架构笔记
windows·笔记·架构·rust
Xudde.3 小时前
AI-Web1 靶机渗透测试(Vulnhub Writeup)
笔记·学习·安全·web安全
盛世宏博北京3 小时前
十二防档案库房总线架构设计:恒湿消毒净化一体机 RS485 组网实战笔记
开发语言·笔记·php·恒湿机·档案八防温湿度
zyseo83 小时前
谷歌SEO 站点地图优化实战:sitemap 是索引候选清单的运营工具
网络·数据库·经验分享·笔记·搜索引擎
浅念-3 小时前
动态规划:线性DP——多状态DP与子数组专题笔记
c++·笔记·算法·leetcode·职场和发展·动态规划·力扣