恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
安全笔试中的撤销/恢复:从双栈到事务性回滚设计
首页
资讯中心
/
安全笔试中的撤销/恢复:从双栈到事务性回滚设计
安全笔试中的撤销/恢复:从双栈到事务性回滚设计
发布时间:2026/10/15 17:21:29
1. 考察点还原安全笔试中的撤销/恢复到底在考什么看到“奇安信笔试 撤销/恢复”这个题目很多人第一反应是这不就是一个栈的经典应用吗双栈法一个栈存撤销历史一个栈存恢复历史LeetCode上类似题目一大堆。但如果你真按刷题思路去准备大概率会在笔试现场踩坑。我接触过不少安全行业的笔试题目也帮几个准备校招的朋友做过模拟测试。奇安信这类安全厂商考撤销/恢复表面上考的是数据结构实际上考的是两件事第一你能不能把“撤销/恢复”从纯文本编辑场景抽象成通用能力第二你知不知道在安全业务里撤销/恢复往往意味着什么——配置回滚、策略变更、审计溯源甚至故障恢复。这已经不是简单的“用两个栈模拟”能cover住的了。笔试中这类题的常见出现形式有两种。一种是纯算法题要求你设计一个数据结构支持插入、删除、撤销、恢复操作时间复杂度有明确要求。另一种是系统设计题给你一个安全产品的场景比如防火墙规则配置平台、终端管控策略下发系统要求你设计撤销/恢复模块要考虑并发、存储、权限、审计。两种都考但第二种更能拉开差距因为绝大多数人只会做第一种。所以这篇文章我打算按两条线来讲一条线是纯算法实现从双栈到命令模式再到事务性撤销把代码写扎实另一条线是把撤销/恢复能力往安全业务场景迁移讲讲配置回滚和策略变更的快照设计。两条线都打通了笔试面试基本就稳了。提示如果你是临时抱佛脚优先把第2章和第3章吃透代码能默写如果你还有两三天时间第4章和第5章的思路一定要看安全厂商特别吃这一套。2. 核心实现思路从双栈到命令模式的演进路线2.1 双栈法的核心思想与隐藏约束双栈法是最基础的实现思路一句话就能讲完维护两个栈undoStack存历史操作redoStack存被撤销的操作。执行新操作时压入undoStack清空redoStack撤销时从undoStack弹出操作并逆执行压入redoStack恢复时从redoStack弹出操作并正执行压入undoStack。代码写出来很简单但笔试真正想考的是边界条件的处理。一个很隐蔽的约束是新操作会清空恢复栈。也就是说你先撤销了A然后执行了一个新操作B那么刚才撤销的A就再也恢复不回来了。这个语义是行业标准——几乎所有主流编辑器的撤销/恢复都是这么设计的。为什么因为恢复栈里保存的是“历史分叉”新操作意味着你选择了新分支旧分支就作废了不然恢复行为会产生歧义。另外一个约束是操作记录的粒度。如果你把每个字符的插入都当成一个操作那么输入“hello”就需要压栈5次撤销时也要逐字符撤销。用户体验很差。所以实际工程中要做“操作合并”——把连续的同类型操作合并成一个批次。比如在输入框里连续打字合并成一次“文本变更”撤销时一次回退整个单词或整个输入过程。这个思路在笔试中也值得主动提一句因为考官/阅卷人看到你能主动考虑操作粒度合并就知道你是有工程意识的人而不是单纯背题。2.2 命令模式与备忘录模式的选型逻辑双栈法虽然直观但有个致命缺陷它假设所有操作都是可逆的而且逆操作是确定性的。文本编辑器里“插入字符”的逆操作是“删除字符”这没问题。但安全业务里很多操作是不可逆的——比如你往密钥管理系统里写入了一个新的密钥你没法“撤销”写这个动作因为密钥已经生效了你能做的只是把状态恢复到写入之前而这需要显式保存历史状态快照。所以在系统设计层面命令模式和备忘录模式是更普适的方案。命令模式的核心思想是把每个操作封装成对象对象内部定义两个方法——execute()和undo()。执行操作时调用execute()撤销时调用undo()。这个模式的好处是操作的可逆逻辑由操作对象自己负责调用方不关心具体怎么撤销。比如“创建用户”这个操作undo()就是“删除用户”“修改密码”这个操作undo()就是“恢复旧密码”——当然旧密码得提前备份。备忘录模式更进一步不保存操作本身而是保存状态快照。每次操作前把当前完整状态存到一个备忘录对象里然后放进历史栈。撤销时直接弹出最近的备忘录把系统状态恢复成快照里的样子。这个方案简单粗暴适合状态不算太庞大的场景。两种模式如何取舍我的经验是三条判断标准操作数量级命令模式每个操作只存一份指令和参数省内存备忘录模式每个快照要存完整状态如果状态很大内存会爆。操作复杂程度如果操作步骤多、逆操作逻辑复杂容易写错备忘录模式更省心因为“保存状态”永远是对的。状态变更频度高频小变更建议命令模式低频大变更建议备忘录模式。举个例子一个配置中心管理着几千条策略每条策略几十个字段。每次变更产生一个全量快照那内存得爆炸。正确做法是存差异diff或者用命令模式记录“这次改了哪个字段、从什么值改成什么值”。2.3 事务性撤销更贴近工程实践的重磅设计如果你的代码做到了命令模式恭喜你已经领先了60%的候选人。但安全厂商的笔试往往还会加一问“如果一个批量操作涉及多个步骤中间某一步失败了你怎么保证数据一致性”这就引出了事务性撤销。事务性撤销的思路是把一个批量操作视为一个事务事务里的每个子操作都走命令模式记录到临时列表。事务执行过程中如果某一步抛异常就把临时列表里已经执行过的子操作逐个undo()相当于整体回滚。事务全部成功之后才把这个临时列表作为一个整体压入全局撤销栈。这样做的好处是撤销栈里的一条记录对应一个完整事务用户一次撤销整个批量变更全部回退不会出现撤了一半的中间态。这个设计在数据库领域叫“事务回滚日志”在安全业务里叫“变更回滚预案”其实是同一个东西。我参与过的某安全产品的策略批量下发功能就是用这个思路做的一批策略推给终端部分终端成功、部分失败失败的触发回滚已成功的也要执行“补偿操作”恢复到下发前状态。这个逻辑在笔试里如果你能主动提出来绝对是个大加分项。3. 完整实现手写一个支持撤销/恢复的编辑器核心模块3.1 类设计与数据结构定义理论讲再多不如直接写代码。我给出一份可直接运行的Python实现模拟一个文本编辑器里撤销/恢复的核心模块。整个项目代号叫模拟项目X是我自己做的一个练手小项目代码量不大但胜在把命令模式和操作合并的思想都用上了。先定义操作类型的枚举和操作对象的基类from abc import ABC, abstractmethod from enum import Enum from collections import deque class OpType(Enum): INSERT insert DELETE delete REPLACE replace class TextOperation(ABC): 操作基类所有可撤销操作都继承这个类 def __init__(self, pos, text): self.pos pos # 操作位置字符索引 self.text text # 操作涉及的文本内容 abstractmethod def execute(self, buffer): 执行操作 pass abstractmethod def undo(self, buffer): 逆操作撤销 pass def merge(self, other): 尝试与相邻操作合并返回合并后的对象或None return None这一段定义了核心抽象。注意merge方法这是前面说的操作合并机制的入口我用它来把连续输入合并成一条记录。实际问题中不合并的话一个300字的文本录入就会产生几百条撤销栈记录既占内存又难用。然后定义三个具体操作类插入、删除、替换class InsertOp(TextOperation): def execute(self, buffer): buffer[self.pos:self.pos] list(self.text) def undo(self, buffer): del buffer[self.pos:self.pos len(self.text)] def merge(self, other): # 连续插入且位置衔接合并成一次插入 if isinstance(other, InsertOp) and other.pos self.pos len(self.text): merged InsertOp(self.pos, self.text other.text) return merged return None class DeleteOp(TextOperation): def __init__(self, pos, text): super().__init__(pos, text) # text保存的是被删除的内容撤销时把它插回去 def execute(self, buffer): del buffer[self.pos:self.pos len(self.text)] def undo(self, buffer): buffer[self.pos:self.pos] list(self.text) def merge(self, other): # 连续删除且位置衔接合并成一次删除 if isinstance(other, DeleteOp) and other.pos self.pos: merged DeleteOp(self.pos, self.text other.text) return merged return None class ReplaceOp(TextOperation): 替换操作保存旧文本和新文本 def __init__(self, pos, old_text, new_text): super().__init__(pos, new_text) self.old_text old_text def execute(self, buffer): buffer[self.pos:self.pos len(self.old_text)] list(self.text) def undo(self, buffer): buffer[self.pos:self.pos len(self.text)] list(self.old_text)替换操作我单独做了个类型因为它的逆操作不是简单的增或删而是“把新的换回旧的”务必保留old_text。这在文本编辑器里对应选区替换场景在配置管理里对应字段值修改场景——一个道理。3.2 撤销/恢复管理器执行、撤销、恢复的完整逻辑有了操作对象接下来就是管理器类。管理器维护两个栈undo栈和redo栈。为了性能和控制内存上限我没有用Python自带的list直接当栈而是用deque并设置最大长度class UndoRedoManager: def __init__(self, max_history50): self.undo_stack deque(maxlenmax_history) self.redo_stack deque(maxlenmax_history) def apply(self, buffer, op: TextOperation): 执行新操作压入undo栈清空redo栈 # 尝试与栈顶操作合并 if self.undo_stack: top self.undo_stack[-1] merged top.merge(op) if merged is not None: self.undo_stack[-1] merged op.execute(buffer) # 合并后仍需执行op但栈里只留合并后的记录 self.redo_stack.clear() return op.execute(buffer) self.undo_stack.append(op) self.redo_stack.clear() def undo(self, buffer): 撤销弹出undo栈顶执行逆操作压入redo栈 if not self.undo_stack: return False op self.undo_stack.pop() op.undo(buffer) self.redo_stack.append(op) return True def redo(self, buffer): 恢复弹出redo栈顶执行操作压回undo栈 if not self.redo_stack: return False op self.redo_stack.pop() op.execute(buffer) self.undo_stack.append(op) return True def history_size(self): return len(self.undo_stack), len(self.redo_stack)这里有个细节值得注意——合并时我仍然先执行了op再把栈顶替换为合并后的对象。这样保证缓冲区内容正确栈顶记录则变成一个更大的合并操作。下次撤销时一次会撤回所有合并过的内容。不过这个合并逻辑有个bug隐患如果top.merge(op)返回了合并对象但是合并对象的text构造有误后续撤销会出错。所以合并方法要写得非常小心最好写单元测试覆盖。我在下面第5章会专门讲测试。3.3 边界处理与内存控制笔试现场容易被追问的点笔试最常见的追问是“你的撤销栈会不会内存爆炸”。所以我在管理器的构造函数里加了max_history参数用deque的maxlen特性超出限制自动丢弃最旧记录。50这个值是我调出来的——文本编辑场景下50步足够用户反悔再往前的操作早就没意义了。内存开销方面一条INSERT操作记录大概存一个位置整数和一段文本Python对象本身有点重量但50条也就是几十KB级别完全可控。另一个边界是空栈撤销。管理器里的if not self.undo_stack: return False就处理了。实际项目中返回False后UI层要灰掉“撤销”按钮属于联动逻辑这里不展开。还有一个笔试爱问的边界光标位置在操作后的修正。文本插入后原来在插入点之后的字符位置都要向后移动删除相反。我在管理器的实现里没有显式处理光标因为Buffer是字符数组操作类自己负责基于pos做片段切割光标的定位逻辑得由编辑器的光标管理模块完成。如果你在笔试时能主动说“我在这个抽象里只处理了数据层的撤销光标位置需要另外维护一个offset映射”会显得思维很完整。4. 进阶场景把撤销/恢复迁移到安全业务中4.1 配置中心的变更回滚怎么设计纯文本编辑器的撤销/恢复做完之后我们再往前迈一步如果撤销的对象是安全产品的策略配置呢假设你参与了一个终端安全管理系统的模拟项目X系统里有一张策略表每条策略包含策略ID、规则表达式、生效范围、优先级等字段。安全运维人员经常需要批量修改策略比如“把所有终端的上传带宽限制从10Mbps改成5Mbps”。这个批量操作涉及的表记录可能有几千条。如果改完发现效果不符合预期需要一条命令整体回滚。你当然可以选择用备忘录模式在每次批量修改前把涉及的所有策略全量快照保存下来。但如果策略数量大、字段多内存消耗极高。更合理的做法是结合命令模式和差异存储每条策略的修改封装成一个UpdatePolicyOp记录policy_id、修改的字段名、旧值、新值。undo()方法就是把对应字段从新值改回旧值。class UpdatePolicyOp: def __init__(self, policy_id, field_name, old_value, new_value): self.policy_id policy_id self.field_name field_name self.old_value old_value self.new_value new_value def execute(self, policy_store): policy_store.update_field(self.policy_id, self.field_name, self.new_value) def undo(self, policy_store): policy_store.update_field(self.policy_id, self.field_name, self.old_value)配合事务性撤销这个批量变更可以这么组织class BatchTransaction: def __init__(self): self.ops [] def add_op(self, op): self.ops.append(op) def execute_all(self, policy_store): executed [] try: for op in self.ops: op.execute(policy_store) executed.append(op) except Exception: # 出错则回滚所有已执行的操作 for op in reversed(executed): op.undo(policy_store) raise这段代码放在笔试里作为“扩展设计”回答能直接体现出你对数据一致性问题的敏感度。安全产品对一致性的要求比普通业务系统高得多——策略下发一半失败导致终端处于无管控状态这是重大生产事故。4.2 策略下发的快照与快速恢复机制再进一步如果批量操作过程中远程终端已经有一部分收到了新策略另一部分没收到怎么回滚这就不是单纯的数据层撤销能解决的了需要引入“两阶段提交”或“补偿事务”的思路。我在实际项目中的做法是先给所有目标终端打一个“预下发标记”终端收到标记后不立即生效而是先缓存。全部终端确认收到标记后再广播“正式生效”指令。如果中间有终端失联或者校验不通过就广播“取消下发”各终端丢弃缓存并维持旧策略。这个方案也常被称为“灰度发布快速回滚”。这里面的核心思想是不要等服务变更出了问题才去撤销而是在变更前就设计好回滚路径。配置管理里叫“变更窗口”金融系统里叫“灰度开关”安全领域里叫“熔断降级”本质都是给变更兜底。撤销/恢复这个经典算法题放到业务里就成了容错设计的一部分。这个进阶点如果你在笔试的最后问答题部分主动写出来跟你和面试官聊“两阶段提交”的效果差不多——因为大部分候选人只会写双栈模拟而你已经在思考分布式环境下的回滚一致性了。4.3 审计日志与撤销记录的联动安全领域的操作还有一个特殊要求——审计。运营人员做了撤销操作系统必须记录谁在什么时间撤销了哪个操作撤销前和撤销后的状态分别是什么。这个审计需求的实现其实就是在命令模式的基础上加一个观察者或事件回调。class AuditedUndoRedoManager(UndoRedoManager): def __init__(self, audit_logger, max_history50): super().__init__(max_history) self.audit_logger audit_logger def undo(self, buffer): if super().undo(buffer): self.audit_logger.log(UNDO, self.undo_stack, buffer)这里没必要展开代码核心考点是你能不能在基础功能之上把可扩展性点说出来。审计日志、权限控制、操作人身份、IP、时间戳这些都是安全产品里绕不开的。笔试题目叫“撤销/恢复”你答出审计联动说明你懂安全。5. 笔试与面试中的高频追问与答题策略5.1 常见问题速查表我把这些年看到的关于撤销/恢复的笔试面试题做了一个汇总做成速查表问题考察点回答要点撤销/恢复的数据结构选型栈的特性后进先出天然匹配撤销顺序双栈配合新操作是否清空恢复栈状态语义理解必须清空否则历史分叉导致歧义连续输入如何优化工程意识操作合并按时间或同类型批量归并操作不可逆怎么办设计模式命令模式保存状态或备忘录模式存快照批量操作如何保证一致性事务思想临时操作列表 失败回溯整体压栈内存无限增长怎么办资源控制限制历史栈长度LRU淘汰最旧记录多端并发修改如何撤销分布式一致版本号或向量时钟冲突时放弃回滚审计需求怎么做安全扩展事件日志记录操作人、时间、前后状态这个表我建议你背下来不仅笔试有用面试深挖的时候也能快速组织语言。5.2 答题节奏与代码风格建议笔试现场时间有限我建议的答题节奏是先用5分钟明确题目要求判断是纯算法还是系统设计题。纯算法题直接上双栈操作合并代码控制在40行左右。系统设计题不要急着写代码先用流程图把操作流画出来再标注每个环节的数据结构。代码风格方面注意三点。第一函数命名要有语义。不要写a()、b()写undo()、execute()、merge()。安全公司的人写代码都很严谨命名风格出卖你的工程习惯。第二边界条件提前写。空栈判断、非法参数校验这些在一开始就写好不要等代码写完了才发现漏了。第三主动写注释解释关键设计。笔试阅卷不会一行行跑代码更多是看思路。你在关键位置写一行注释“新操作必须清空redo栈避免历史分叉”阅卷人一眼就知道你懂原理。5.3 一个被忽视的加分项内存占用分析很多候选人代码写对了但追问“你这个方案的内存复杂度是多少”时卡壳。双栈法最坏情况下两个栈各存N个操作每个操作对象O(1)大小不考虑文本内容长度总空间复杂度是O(N)。但如果操作对象内部挂了完整文本内容比如插入操作存了整个句子那空间复杂度还要乘以文本长度L实际是O(N×L)。我习惯在答题末尾主动补一句“本实现中每个操作对象持有文本内容最坏空间复杂度为O(N×L)如果文本较长可以通过游标或指针共享来优化但会增加实现复杂度。”这句话看似轻描淡写实际体现了你在性能和复杂度之间的权衡意识。很多笔试官就吃这一套。5.4 实测中的常见错误清单我在调试模拟项目X的过程中踩过几次坑列出来帮大家避雷。第一个坑是合并逻辑破坏了撤销的正确性。我最初把合并写成了在execute之后直接修改栈顶但合并后的对象包含了两次操作的总文本而缓冲区只执行了第二次操作的变更。结果就是撤销时把第一次操作的成果也一并撤掉了。后来我改成“先execute再替换栈顶”并且专门写了测试用例验证三种合并场景。第二个坑是缓冲区类型的混用。我一开始用字符串管理文本执行插入操作时buffer[:pos] text buffer[pos:]看起来很合理。但撤销操作需要按索引精确切片删除字符串的不可变性导致每次都要重建性能很差。后来改成字符数组list[str]所有操作直接在原数组上做切片赋值性能提升明显。笔试如果考到文本编辑器实现建议直接用数组面试官问起来也好聊。第三个坑是合并条件的过度宽松。DELETE操作的合并条件是other.pos self.pos也就是连续删除时新操作总是从同一个位置开始删。这在逻辑上是正确的因为每次删除后原来的后一个字符会移动到当前位置。但如果中间夹了一次光标移动合并就不该发生。解决办法是把光标位置作为合并判断条件之一——只有光标位置没有移动过的连续删除才合并。这个细节笔试里提到的概率不低大家留意一下。个人实操体会做这个模拟项目X之前我一直以为撤销/恢复就是个双栈题。等真正撸完代码、踩完上面的坑才意识到中学课本里“数据结构是算法的基础”这句话有多实在——但光有栈是不够的设计模式、事务思维、审计扩展这些工程化的东西才是安全厂笔试真正想筛的能力。如果你时间紧我最后给一条最实在的建议把第3章的代码自己动手敲两遍第一遍不看答案第二遍加合并逻辑和事务批量操作。能连续两遍跑通并讲清楚每个边界条件去笔试遇到类似的撤销/恢复题基本就稳了。这个题考察的从来就不是“会不会用栈”而是“你用栈的时候脑子里有没有装着真实业务”。