简介:这份资源是武汉理工大学面向对象与多线程综合实验的档案管理系统完整项目,面向计算机相关专业学生及需要巩固OOP与并发编程的开发者。项目围绕系统管理员、档案管理员、普通用户三种角色展开,涵盖用户注册登录、权限控制、档案增删改查、条件搜索、日志记录、多线程性能优化与异常处理等七次迭代功能,可帮助读者理解类之间的继承、聚合与关联关系,并掌握synchronized同步与锁机制在并发场景下的实际运用。压缩包共432个文件,以294个class编译文件、96个java源码、28个xml配置及少量jar、iml等工程文件为主,整体约3.21MB,目录结构清晰,便于对照源码梳理模块划分与迭代过程。目前已有2213人学习下载,适合作为课程设计参考、实验报告复盘或面向对象与多线程综合练习的实战样本。
1. 档案管理系统这个课设,为什么多线程一上就翻车
档案管理系统,几乎是每届计算机专业学生绕不开的面向对象与多线程综合实验。表面需求很朴素:录入档案、查询档案、借阅归还、权限区分。但真正让大多数人卡住的,不是界面,也不是数据库,而是那句“综合实验”里藏着的多线程要求。单线程跑得好好的代码,一旦把借阅、归档、日志写入丢进线程池,立刻出现数据错乱、文件写坏、界面卡死。我见过太多人在这里翻车,最后交上去的系统只是把线程当装饰,根本没跑出并发价值。
这篇笔记就围绕这个标题拆开讲:档案管理系统到底该怎么设计类结构,多线程该加在哪几个真实瓶颈上,参数怎么设,坑在哪。适合正在做这个实验、或者想把一个单机管理系统改造成能扛并发的小工具的人。我不会给你一个虚构的“完整源码包”,而是把可复现的类设计、线程模型、同步策略和排错方法一步步写清楚,你照着改自己的代码就能跑。
2. 面向对象设计先立住:档案管理系统的类结构与职责边界
很多人一上来就写MainFrame里塞几百行,然后发现多线程根本没法加。面向对象部分如果没拆干净,后面并发就是灾难。这一章先把领域模型和分层定下来,再谈线程。
2.1 从需求里抽出四个核心实体类
档案管理系统的业务对象其实就四类:档案(Archive)、用户(User)、借阅记录(BorrowRecord)、操作日志(OperationLog)。不要把它们写成数据库表的镜像,而是按职责建模。
# domain.py from dataclasses import dataclass, field from datetime import datetime from enum import Enum class Role(Enum): ADMIN = "admin" CLERK = "clerk" READER = "reader" @dataclass class User: user_id: str name: str role: Role borrowed_ids: list = field(default_factory=list) @dataclass class Archive: archive_id: str title: str category: str status: str = "in_store" # in_store / borrowed / archived version: int = 0 # 乐观锁版本号 @dataclass class BorrowRecord: record_id: str user_id: str archive_id: str borrow_time: datetime return_time: datetime = None @dataclass class OperationLog: log_id: str user_id: str action: str target_id: str timestamp: datetime = field(default_factory=datetime.now)逻辑说明:Archive里的version字段是后面并发控制的关键,先埋下。User.borrowed_ids用列表而不是直接存对象,避免循环引用导致序列化困难。参数上,status用字符串枚举而不是整数,是为了日志可读,代价是查询时要做映射,数据量小可以接受。
2.2 服务层与仓储层分离,为线程安全留接口
面向对象的核心不是继承多,而是职责单一。把“业务规则”和“数据存取”分开,后面加锁才知道锁谁。
# repository.py import threading from domain import Archive class ArchiveRepository: def __init__(self): self._store = {} # archive_id -> Archive self._lock = threading.RLock() # 可重入锁,读多写少可换读写锁 def get(self, archive_id): with self._lock: return self._store.get(archive_id) def save(self, archive: Archive): with self._lock: self._store[archive.archive_id] = archive def list_by_category(self, category): with self._lock: return [a for a in self._store.values() if a.category == category]逻辑说明:仓储层内部持锁,对外暴露的方法都是原子的。RLock允许同一线程重复进入,适合服务层组合调用。参数上,如果读远多于写,可以把RLock换成threading.Lock加读写分离,但课设规模下RLock足够,别过度设计。
2.3 用依赖注入把服务层串起来
服务层只依赖仓储接口,不关心数据从哪来。这样单测时能换内存实现,并发测试时也能注入带延迟的假仓储来复现竞态。
# service.py from domain import Archive, BorrowRecord, OperationLog from repository import ArchiveRepository class ArchiveService: def __init__(self, repo: ArchiveRepository, log_queue): self.repo = repo self.log_queue = log_queue # 日志走队列,异步落盘 def borrow(self, user, archive_id): archive = self.repo.get(archive_id) if archive is None: raise ValueError("档案不存在") if archive.status != "in_store": raise RuntimeError("档案不可借") archive.status = "borrowed" archive.version += 1 self.repo.save(archive) self.log_queue.put(OperationLog( log_id=f"L{archive.version}", user_id=user.user_id, action="borrow", target_id=archive_id )) return BorrowRecord( record_id=f"R{archive_id}-{user.user_id}", user_id=user.user_id, archive_id=archive_id, borrow_time=__import__("datetime").datetime.now() )逻辑说明:borrow里先读后写,中间没有全局锁,所以两个线程同时借同一份档案时可能都读到in_store。这就是下一章要解决的并发问题。参数上,log_queue用queue.Queue即可,容量设 1000 防止内存暴涨。
3. 多线程到底加在哪:三个真实瓶颈与线程模型选型
课设要求“多线程”,但没人规定必须到处开线程。我的经验是:只在 I/O 等待和可并行的批量操作上加,CPU 密集的排序检索反而要小心 GIL。这一章把线程模型和参数讲透。
3.1 日志落盘:生产者-消费者模型的最小实现
日志写入是典型 I/O 瓶颈,每次借阅都同步写文件会让界面卡顿。用队列加后台线程,主线程只负责投递。
# log_worker.py import threading, queue, json, time class LogWorker(threading.Thread): def __init__(self, log_queue: queue.Queue, file_path: str, batch_size=50, flush_interval=1.0): super().__init__(daemon=True) self.log_queue = log_queue self.file_path = file_path self.batch_size = batch_size self.flush_interval = flush_interval self._stop = threading.Event() def run(self): buffer = [] last_flush = time.time() while not self._stop.is_set(): try: log = self.log_queue.get(timeout=0.2) buffer.append(log) except queue.Empty: pass if len(buffer) >= self.batch_size or (time.time() - last_flush) > self.flush_interval: self._flush(buffer) buffer.clear() last_flush = time.time() def _flush(self, buffer): if not buffer: return with open(self.file_path, "a", encoding="utf-8") as f: for log in buffer: f.write(json.dumps(log.__dict__, default=str) + "\n") def stop(self): self._stop.set()逻辑说明:batch_size=50和flush_interval=1.0是两个关键参数。批量太小失去意义,太大丢数据风险高。daemon=True保证主程序退出时线程不阻塞。注意queue.get(timeout=0.2)不能省,否则_stop无法及时响应。
3.2 批量导入档案:线程池而不是裸线程
批量导入几百条档案时,逐条写库慢。用ThreadPoolExecutor控制并发数,避免开几百个线程把内存打爆。
# importer.py from concurrent.futures import ThreadPoolExecutor, as_completed def import_archives(service, archive_list, max_workers=4): results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(service.repo.save, a): a for a in archive_list } for future in as_completed(future_map): archive = future_map[future] try: future.result() results.append((archive.archive_id, "ok")) except Exception as e: results.append((archive.archive_id, str(e))) return results逻辑说明:max_workers=4是经验值,I/O 密集可以到 8,但课设机器上 4 足够。as_completed让结果按完成顺序返回,不要用map因为会阻塞。注意这里直接调repo.save,仓储内部有锁,所以并发安全,但服务层的业务校验被跳过了,导入场景可以接受。
3.3 查询与统计:读写锁的取舍
查询档案列表时如果加互斥锁,多个读者会互相阻塞。Python 没有内置读写锁,但可以用threading.Condition手写一个,或者直接用RLock接受读读互斥。我的建议是课设规模下用RLock,别为了炫技引入复杂读写锁,调试成本远大于收益。
| 场景 | 推荐锁 | 理由 |
|---|---|---|
| 单条读写 | RLock | 实现简单,不易死锁 |
| 批量导入 | 仓储内部锁 | 线程池并发调用,锁粒度到方法 |
| 日志写入 | 队列 | 无锁,生产者消费者解耦 |
| 统计报表 | 快照复制 | 先复制数据再计算,避免长时间持锁 |
提示:不要在主线程里对仓储做长时间遍历,先把
list_by_category结果复制出来再处理,否则后台日志线程会被饿死。
4. 并发借阅的坑:从超卖到日志丢失的排查记录
这一章专门讲踩坑。档案管理系统里最典型的并发 bug 就是同一份档案被两个人同时借走,以及日志线程退出时丢最后一批数据。下面每条按现象、原因、解决写。
4.1 同一档案被借两次:检查-写入不是原子操作
现象:两个读者同时借同一份档案,系统都提示成功,数据库里出现两条借阅记录。原因:borrow方法里get和save之间没有锁保护,两个线程都读到in_store。解决:在服务层加乐观锁,保存时校验版本号。
def borrow_with_version(self, user, archive_id): archive = self.repo.get(archive_id) if archive is None or archive.status != "in_store": raise RuntimeError("不可借") old_version = archive.version archive.status = "borrowed" archive.version = old_version + 1 # 模拟 CAS:仓储层提供条件更新 ok = self.repo.compare_and_set(archive_id, old_version, archive) if not ok: raise RuntimeError("并发冲突,请重试") return True逻辑说明:compare_and_set在仓储锁内比较版本号,不一致就返回 False。参数上,重试次数建议 3 次,超过就提示用户稍后再试。
4.2 日志线程退出丢数据:stop 之后没有 join
现象:程序关闭后,最后几条借阅日志没写进文件。原因:LogWorker是 daemon 线程,主线程退出时直接被杀,缓冲区里未 flush 的数据丢失。解决:在退出前调用stop()并join(timeout=5)。
worker = LogWorker(log_queue, "logs.txt") worker.start() # ... 业务运行 ... worker.stop() worker.join(timeout=5) # 等待最后一批 flush逻辑说明:join的超时是后悔药,防止线程卡死导致程序无法退出。如果超时后还没结束,记录一条错误日志再强制退出。
4.3 界面卡死:在 UI 线程里等 future.result()
现象:点击批量导入后窗口无响应。原因:在 UI 事件回调里直接future.result(),阻塞了事件循环。解决:把结果处理放到独立线程,或者用轮询加after回调。
# 错误做法 result = executor.submit(import_task).result() # 卡死 # 正确做法:后台线程处理,UI 只更新进度 def on_import_done(future): try: data = future.result() ui.update_table(data) except Exception as e: ui.show_error(str(e)) executor.submit(import_task).add_done_callback(on_import_done)逻辑说明:add_done_callback在 future 完成时由执行线程调用,UI 更新要再切回主线程,具体看框架。参数上,回调里不要做耗时操作。
4.4 死锁:嵌套锁顺序不一致
现象:程序运行一段时间后完全卡住,CPU 占用为零。原因:线程 A 先拿档案锁再拿日志锁,线程 B 反过来。解决:统一锁顺序,或者用超时锁acquire(timeout=1)。
# 统一顺序:先仓储锁,再日志队列(队列本身无锁) with self.repo._lock: self.log_queue.put(log)逻辑说明:队列的put内部有锁,但不会和仓储锁形成环。如果必须多把锁,用threading.Lock的acquire(timeout=...)并处理失败。
4.5 性能反而下降:线程数超过 I/O 承载
现象:把max_workers从 4 调到 32 后,导入更慢了。原因:磁盘随机写和 GIL 切换开销导致上下文切换成本超过收益。解决:用time模块实测,找到拐点。
import time for workers in [2, 4, 8, 16]: start = time.time() import_archives(service, data, max_workers=workers) print(workers, time.time() - start)逻辑说明:课设机器上通常 4 到 8 是拐点。参数不是越大越好,要实测。
5. 让系统可验证:并发正确性测试与性能基线
写完代码不算完,得证明多线程真的安全。这一章给两个可复现的验证方法,一个测正确性,一个测吞吐。
5.1 用屏障制造最大并发,复现竞态
threading.Barrier能让多个线程在同一时刻开始,最大化撞车概率。
import threading from service import ArchiveService def test_concurrent_borrow(service, archive_id, user_count=10): barrier = threading.Barrier(user_count) errors = [] def worker(uid): barrier.wait() # 所有线程在此对齐 try: service.borrow_with_version(fake_user(uid), archive_id) except RuntimeError as e: errors.append(str(e)) threads = [threading.Thread(target=worker, args=(i,)) for i in range(user_count)] for t in threads: t.start() for t in threads: t.join() return errors逻辑说明:Barrier的parties必须等于线程数,否则永久阻塞。预期结果是只有一个线程成功,其余抛并发冲突。如果多个成功,说明锁没生效。
5.2 性能基线:单线程 vs 线程池的导入耗时
不要凭感觉说“多线程更快”,用数据说话。
| 线程数 | 导入 500 条耗时(秒) | 备注 |
|---|---|---|
| 1 | 2.4 | 基线 |
| 4 | 0.9 | 推荐 |
| 8 | 0.8 | 提升有限 |
| 16 | 1.3 | 上下文切换反噬 |
注意:测试时关闭日志输出,否则磁盘 I/O 会干扰结果。每次测试前清空数据,避免缓存影响。
5.3 一个容易忽略的验证点:日志完整性
并发结束后,检查日志文件行数是否等于成功操作数。如果少了,说明队列有丢数据。
def verify_log_count(log_path, expected): with open(log_path, encoding="utf-8") as f: lines = [l for l in f if l.strip()] assert len(lines) == expected, f"日志丢失:{len(lines)} != {expected}"逻辑说明:这个断言要在worker.stop()和join之后执行。参数上,expected是成功借阅次数,不是请求次数。
6. 从课设到能用的最后一步:把配置和重试做成可调参数
课设交完就扔太可惜。把线程数、批量大小、重试次数抽成配置文件,这个系统就能当小工具用。我一般会留一个config.yaml,改参数不用动代码。
# config.yaml thread_pool: max_workers: 4 queue_size: 1000 log: batch_size: 50 flush_interval: 1.0 file_path: "logs/operation.log" retry: max_attempts: 3 backoff_seconds: 0.1读取时用dataclass映射,避免到处dict["key"]。
import yaml from dataclasses import dataclass @dataclass class ThreadPoolConfig: max_workers: int = 4 queue_size: int = 1000 def load_config(path="config.yaml"): with open(path, encoding="utf-8") as f: raw = yaml.safe_load(f) return ThreadPoolConfig(**raw.get("thread_pool", {}))逻辑说明:**raw展开时如果多出字段会报错,所以配置类只声明需要的字段。参数上,backoff_seconds用于乐观锁重试的退避,0.1 秒在课设场景足够。
最后一个技巧:把并发冲突做成可观测指标,每次重试都计数,运行结束后打印。这样你才知道锁到底有没有在干活。
class RetryCounter: def __init__(self): self.count = 0 self._lock = threading.Lock() def inc(self): with self._lock: self.count += 1我自己的习惯是,任何加了锁的地方,都要有一个计数器或者日志能证明它被触发过。否则你永远不知道那段同步代码是保险还是摆设。这个课设做完,真正值钱的不是档案管理本身,而是你亲手复现过竞态、丢数据、死锁和性能拐点。希望帮到你。
本文还有配套的精品资源,点击获取